Menguasai Doctrine level lanjut: QueryBuilder dan DQL, hydration modes, pagination untuk dataset besar, menyelesaikan masalah N+1 dengan fetch join & EAGER, serta lifecycle events, listeners, dan subscribers untuk logika otomatis saat entity berubah.

Di episode 6 kita mengenal Doctrine dasar. Episode 21 adalah level yang membedakan "bisa pakai ORM" dan "tahu yang sedang terjadi di database": query kompleks, efisiensi hydration, pagination besar, dan perangkap paling terkenal — N+1.
Mengapa ini penting? Karena aplikasi yang tampak berjalan baik bisa saja mengirim ratusan query per halaman tanpa disadari. Profiler di episode 15 akan menunjukkan betapa cepat masalah ini meledak saat dataset membesar. Menguasai advanced Doctrine berarti aplikasi tetap cepat meski data tumbuh.
QueryBuilder menyusun query secara terprogram — aman dari SQLi (parameter selalu terikat) dan mudah dikondisikan:
public function findPublishedByTag(string $tag, int $limit): array
{
return $this->createQueryBuilder('a')
->innerJoin('a.tags', 't')
->where('a.published = :published')
->andWhere('t.slug = :tag')
->setParameter('published', true)
->setParameter('tag', $tag)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}| Method | Fungsi |
|---|---|
select/from | Kolom & source |
innerJoin/leftJoin | Relasi antar entity |
where/andWhere/orWhere | Kondisi (parameter :name) |
setParameter | Ikat nilai — anti SQLi |
orderBy/groupBy/having | Urutan & agregasi |
setMaxResults/setFirstResult | Limit & offset (pagination) |
Keuntungan QueryBuilder dibanding DQL string: kondisi bisa dirakit bertahap (misal filter opsional), dan query ter-render bisa diperiksa sebelum dieksekusi.
DQL (Doctrine Query Language) memakai entity & property, bukan tabel & kolom:
$query = $this->getEntityManager()->createQuery(
'SELECT a, u
FROM App\Entity\Article a
JOIN a.author u
WHERE a.published = :published
ORDER BY a.createdAt DESC'
)->setParameter('published', true);
$articles = $query->getResult();Perbedaan kunci dengan SQL: FROM App\Entity\Article a (bukan FROM article), a.author (relasi objek, bukan kolom). Doctrine menerjemahkannya ke SQL yang tepat untuk database yang dipakai.
Saat query dijalankan, Doctrine mengubah row menjadi struktur PHP — ini hydration. Pilih mode yang sesuai kebutuhan:
use Doctrine\ORM\Query;
$query = $qb->getQuery();
// Objek penuh (default) — termahal, paling nyaman
$entities = $query->getResult();
// Array dari objek yang di-query
$arrays = $query->getResult(Query::HYDRATE_ARRAY);
// Skalar untuk laporan / agregasi
$scalars = $query->getResult(Query::HYDRATE_SCALAR);| Mode | Keluaran | Biaya | Cocok untuk |
|---|---|---|---|
HYDRATE_OBJECT | Entity object | Mahal | Manipulasi/update data |
HYDRATE_ARRAY | Nested array | Sedang | Baca & tampilkan |
HYDRATE_SCALAR | Flat scalar | Murah | Agregasi, laporan |
Aturan praktis: untuk laporan & agregasi (jumlah per bulan, sum), jangan tarik entity — pakai scalar. Untuk tampilan read-heavy, array menghemat overhead objek.
setFirstResult()/setMaxResults() sederhana, tapi di dataset besar memburuk karena database tetap harus menscan semua row sebelum offset. Dua peningkatan:
$countQb = (clone $qb)
->select('COUNT(a.id)')
->setMaxResults(null)
->setFirstResult(0);
$total = (int) $countQb->getQuery()->getSingleScalarResult();composer require pagerfanta/pagerfanta
composer require pagerfanta/doctrine-orm-adapteruse Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
public function paginate(int $page = 1, int $perPage = 20): Pagerfanta
{
$qb = $this->createQueryBuilder('a')->orderBy('a.createdAt', 'DESC');
return (new Pagerfanta(new DoctrineORMAdapter($qb)))
->setMaxPerPage($perPage)
->setCurrentPage($page);
}Pagerfanta menangani hitungan total, halaman, dan navigasi — sehingga controller tidak menulis boilerplate pagination setiap kali.
Gejala: halaman daftar 100 artikel mengirim 101 query. Penyebabnya: 1 query daftar + 100 query author (satu per artikel). Doctrine default lazy-loads relasi:
// Naif — memicu 1 + N query
public function findAllWithAuthors(): array
{
return $this->findAll(); // author di-load per objek!
}Solusi: fetch join — gabungkan relasi ke query utama:
public function findAllWithAuthors(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.author', 'u')
->addSelect('u') // kunci: select relasi sekaligus
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}addSelect('u') membuat Doctrine eager-load author dalam 1 query (JOIN). Alternatif deklaratif di entity:
#[ORM\ManyToOne(fetch: 'EAGER')]
private ?User $author = null;Tip
Debug N+1 pakai profiler: buka panel Doctrine — jika melihat puluhan query identik dengan pola WHERE id = ?, itu N+1. Deteksi otomatis bisa memakai bundle doctrine-bundle dengan opsi when_matching_criteria / logging query — atau sederhananya: biasakan melihat panel Doctrine setiap kali merender daftar.
Doctrine memicu lifecycle events saat entity berubah — tempat terbaik untuk logika otomatis:
| Event | Kapan terjadi |
|---|---|
prePersist | Sebelum insert |
postPersist | Setelah insert |
preUpdate | Sebelum update |
postUpdate | Setelah update |
preRemove | Sebelum delete |
postLoad | Setelah entity di-load |
Implementasi lewat listener (service terpisah) atau subscriber (multi-event):
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::prePersist)]
final class ArticleListener
{
public function prePersist(Article $article): void
{
if ($article->getSlug() === null) {
$article->setSlug($this->slugify($article->getTitle()));
}
}
}Autoconfigure mendaftarkan listener otomatis. Contoh pemakaian nyata:
Perhatikan: prePersist terjadi sebelum flush — ideal untuk mengisi field default tanpa logika terpisah di controller.
Pada episode 21 ini, kalian telah menguasai Doctrine level lanjut.
Inti yang harus dibawa pulang:
addSelect) atau fetch: 'EAGER'.Di episode 22 selanjutnya kita mengganti model eksekusi: Worker Mode — menjalankan Symfony dengan FrankenPHP dan RoadRunner, bedah keuntungannya terhadap FPM, dan praktik deploy worker mode di production. Sampai jumpa di episode 22!