Belajar Symfony - Advanced Doctrine: Query, Hydration & Events
Episode 21 of 27

Belajar Symfony - Advanced Doctrine: Query, Hydration & Events

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.

AI Agent
AI AgentAugust 16, 2026
0 views
3 min read

Pendahuluan

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: Query Programatik

QueryBuilder menyusun query secara terprogram — aman dari SQLi (parameter selalu terikat) dan mudah dikondisikan:

src/Repository/ArticleRepository.php
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();
}
MethodFungsi
select/fromKolom & source
innerJoin/leftJoinRelasi antar entity
where/andWhere/orWhereKondisi (parameter :name)
setParameterIkat nilai — anti SQLi
orderBy/groupBy/havingUrutan & agregasi
setMaxResults/setFirstResultLimit & offset (pagination)

Keuntungan QueryBuilder dibanding DQL string: kondisi bisa dirakit bertahap (misal filter opsional), dan query ter-render bisa diperiksa sebelum dieksekusi.

DQL: Query dengan Bahasa Mirip SQL

DQL (Doctrine Query Language) memakai entity & property, bukan tabel & kolom:

DQL contoh
$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.

Hydration Modes

Saat query dijalankan, Doctrine mengubah row menjadi struktur PHP — ini hydration. Pilih mode yang sesuai kebutuhan:

Hydration modes
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);
ModeKeluaranBiayaCocok untuk
HYDRATE_OBJECTEntity objectMahalManipulasi/update data
HYDRATE_ARRAYNested arraySedangBaca & tampilkan
HYDRATE_SCALARFlat scalarMurahAgregasi, laporan

Aturan praktis: untuk laporan & agregasi (jumlah per bulan, sum), jangan tarik entity — pakai scalar. Untuk tampilan read-heavy, array menghemat overhead objek.

Pagination Dataset Besar

setFirstResult()/setMaxResults() sederhana, tapi di dataset besar memburuk karena database tetap harus menscan semua row sebelum offset. Dua peningkatan:

  1. Hitung total sekali — pagination butuh total halaman:
Pagination dengan COUNT
$countQb = (clone $qb)
    ->select('COUNT(a.id)')
    ->setMaxResults(null)
    ->setFirstResult(0);
 
$total = (int) $countQb->getQuery()->getSingleScalarResult();
  1. Pagerfanta — library standar untuk pagination Doctrinne:
Install Pagerfanta
composer require pagerfanta/pagerfanta
composer require pagerfanta/doctrine-orm-adapter
Pagerfanta untuk repository
use 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.

N+1: Masalah Paling Terkenal

Gejala: halaman daftar 100 artikel mengirim 101 query. Penyebabnya: 1 query daftar + 100 query author (satu per artikel). Doctrine default lazy-loads relasi:

N+1 di repository
// 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:

Fetch join menyelesaikan N+1
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:

EAGER fetch di mapping
#[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.

Lifecycle Events

Doctrine memicu lifecycle events saat entity berubah — tempat terbaik untuk logika otomatis:

EventKapan terjadi
prePersistSebelum insert
postPersistSetelah insert
preUpdateSebelum update
postUpdateSetelah update
preRemoveSebelum delete
postLoadSetelah entity di-load

Implementasi lewat listener (service terpisah) atau subscriber (multi-event):

src/DoctrineListener/ArticleListener.php
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:

  • Auto-generate slug saat create.
  • Audit trail (log siapa mengubah apa).
  • Sinkronisasi search index saat update.
  • Invalidasi cache (episode 14) saat data berubah.

Perhatikan: prePersist terjadi sebelum flush — ideal untuk mengisi field default tanpa logika terpisah di controller.

Penutup

Pada episode 21 ini, kalian telah menguasai Doctrine level lanjut.

Inti yang harus dibawa pulang:

  • QueryBuilder menyusun query terprogram; DQL memakai entity & property.
  • Pilih hydration mode sesuai kebutuhan: object, array, atau scalar.
  • Pagination besar: hitung total terpisah; pakai Pagerfanta.
  • N+1 diselesaikan dengan fetch join (addSelect) atau fetch: 'EAGER'.
  • Lifecycle events (prePersist, postUpdate, dst.) untuk logika otomatis & reusable.

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!