Membangun lapisan database dengan Doctrine ORM: menginstall driver, membuat entity, menggenerasi dan menjalankan migrasi, memahami repository dan relasi antar entity, mengisi data dengan fixtures, serta praktik CRUD blog dari entity hingga controller.

Setelah di episode 5 kalian merender HTML dari data array, saatnya data itu datang dari sumber yang benar: database. Episode 6 memperkenalkan Doctrine ORM — lapisan model dalam arsitektur MVC Symfony.
Mengapa memakai ORM, bukan query SQL langsung? Karena ORM mengubah row database menjadi objek (entity) yang bisa diolah sebagai kode PHP biasa, sekaligus menjaga keamanan dari SQL injection (prepared statement otomatis). Namun ORM bukan sihir tanpa biaya: tanpa memahami bagaimana ia menggenerasi SQL, kalian tidak akan bisa memperbaiki query lambat — masalah yang kita bedah mendalam di episode 21.
composer require doctrine/ormFlex otomatis menambahkan bundle dan konfigurasi. Selanjutnya atur koneksi di .env:
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# PostgreSQL: postgresql://app:pass@127.0.0.1:5432/app
# MySQL: mysql://app:pass@127.0.0.1:3306/appLalu buat database-nya:
php bin/console doctrine:database:createUntuk SQLite, perintah di atas cukup membuat file database kosong.
Entity adalah class PHP yang dipetakan ke tabel. Buat dengan MakerBundle:
php bin/console make:entity ArticlePerintah interaktif ini menghasilkan class dengan atribut mapping:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
private ?string $body = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
// getter dan setter untuk setiap property...
}Setiap #[ORM\Column] adalah kolom tabel; #[ORM\Id] + #[ORM\GeneratedValue] menandai primary key auto-increment. Doctrine menyimpulkan tipe SQL dari tipe PHP: ?string → VARCHAR, ?\DateTimeImmutable → DATETIME.
Setelah entity berubah, jangan pernah menulis SQL manual untuk mengubah skema — gunakan migrasi:
php bin/console make:migration
php bin/console doctrine:migrations:migratemake:migration membandingkan skema terakhir dengan entity saat ini, lalu menulis file di migrations/ — kalian bisa meninjau SQL-nya sebelum dijalankan:
[OK] Generated migration "Version20260816120000" ...
"migrations/Version20260816120000.php"Migrasi adalah riwayat perubahan skema yang bisa direview dan di-rollback (doctrine:migrations:migrate prev). Inilah alasan tim Symfony jarang bertengkar soal struktur database: semuanya versi dalam kode, termasuk di production (kita jalankan saat deploy di episode 23).
ArticleRepository yang di-generate adalah gerbang query:
public function findLatest(int $limit): array
{
return $this->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}Metode bawaan yang sering dipakai:
| Method | Fungsi |
|---|---|
find($id) | Cari berdasarkan primary key |
findAll() | Semua record |
findBy(['published' => true]) | Filter sederhana |
findOneBy(['slug' => $slug]) | Satu record berdasarkan kriteria |
createQueryBuilder() | Query kompleks (episode 21) |
Rangkaian minimal CRUD: controller menampilkan daftar, membuat artikel, dan menampilkan detail.
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
#[Route('/articles', name: 'app_article_')]
class ArticleController extends AbstractController
{
#[Route('', name: 'index')]
public function index(ArticleRepository $repo): Response
{
return $this->render('article/index.html.twig', [
'articles' => $repo->findLatest(10),
]);
}
#[Route('/new', name: 'new', methods: ['POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$article = new Article();
$article->setTitle($request->request->get('title'));
$article->setBody($request->request->get('body'));
$article->setCreatedAt(new \DateTimeImmutable());
$em->persist($article);
$em->flush();
return $this->redirectToRoute('app_article_index');
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(Article $article): Response
{
return $this->render('article/show.html.twig', [
'article' => $article,
]);
}
}Perhatikan dua hal penting:
persist() + flush() — pola wajib Doctrine: persist memberi tahu unit of work, flush menulis ke database dalam satu transaksi.Article $article pada route /articles/{id} membuat Symfony mencari entity dengan id dari URL (param converter). Jika tidak ada, otomatis 404.Untuk blog yang realistis, Artikel dimiliki Penulis. Tambahkan relasi:
php bin/console make:entity ArticlePilih opsi relasi ManyToOne menuju User. Hasilnya di entity:
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;Pola relasi utama: ManyToOne/OneToMany (banyak artikel → satu penulis), ManyToMany (tag), dan OneToOne (profil pengguna). Kita dalami query dan optimization relasi ini di episode 21 — terutama masalah N+1 yang muncul persis karena relasi.
Data development diisi lewat fixtures — bukan manual via SQL:
composer require --dev doctrine/doctrine-fixtures-bundle
php bin/console make:fixturesuse App\Entity\Article;
public function load(ObjectManager $manager): void
{
for ($i = 0; $i < 20; $i++) {
$article = new Article();
$article->setTitle("Artikel demo ke-{$i}");
$article->setBody('Konten artikel...');
$article->setCreatedAt(new \DateTimeImmutable("-{$i} days"));
$manager->persist($article);
}
$manager->flush();
}Jalankan dengan php bin/console doctrine:fixtures:load — data 20 artikel siap dipakai di episode 7.
Warning
Jangan pernah memakai make:entity untuk menambah kolom tanpa membuat migrasi baru. Skema production dan dev akan menyimpang, dan doctrine:migrations:migrate di production bisa gagal karena skema tak sinkron. Pola disiplin: ubah entity → make:migration → review SQL → migrate.
Pada episode 6 ini, kalian telah membangun lapisan database penuh dengan Doctrine.
Inti yang harus dibawa pulang:
composer require doctrine/orm; atur DATABASE_URL di .env.make:entity untuk boilerplate.Repository; findBy() untuk sederhana, createQueryBuilder() untuk kompleks.persist() + flush() untuk menulis; param converter mengubah {id} menjadi entity otomatis.Di episode 7 selanjutnya, kita mengubah input dari sembarangan menjadi tervalidasi: Forms & Validation — FormType dengan data binding, proteksi CSRF otomatis, constraint validation (attribute), custom validator, validation groups, dan theming form di Twig. Sampai jumpa di episode 7!