Decorator membungkus satu objek secara sinkron dan berurutan; Observer Pattern bekerja dengan arah yang melebar: satu kejadian disiarkan kepada banyak pendengar yang mendaftar, tanpa produsen kejadian pernah mengenal satu pun dari mereka. Ketika sebuah pesanan selesai dibuat, aplikasi nyata ingin mengirim email, mengurangi stok, dan mencatat analitik — tiga reaksi yang tidak ada urusannya satu sama lain. Chapter ini membahas interface SPL bawaan PHP, membangun event dispatcher dengan kontrak buatan, menyentuh standar PSR-14, lalu menutup dengan diskusi jujur kapan pola ini justru menyulitkan.
Masalah: Siapa yang Perlu Tahu #
Pendekatan paling lugas adalah memanggil semua reaksi secara langsung dari layanan utama:
<?php
final class LayananPesananLama
{
public function buat(string $nomor, int $total, string $emailPembeli): void
{
// ... menyimpan pesanan ke database ...
// Setiap reaksi baru menuntut method ini disunting lagi
echo "[email] Faktur pesanan $nomor dikirim ke $emailPembeli\n";
echo "[stok] Stok dikurangi untuk pesanan $nomor\n";
echo "[analitik] Pesanan $nomor masuk ke dashboard\n";
}
}Tiga baris echo itu tampak polos, tetapi setiap baris adalah tanggungan: menambah reaksi keempat berarti menyunting method ini, dan lama-lama method tunggal mengetahui seluruh efek samping aplikasi — god method yang melanggar pembagian tanggung jawab dari chapter Single Responsibility Principle
. Ketergantungannya juga dua arah: layanan pesanan mengenal pengirim email, pengelola stok, dan dashboard sekaligus, padahal ketiganya tidak saling membutuhkan. Observer memutus simpul itu dengan satu pembalikan: biarkan pihak yang ingin tahu yang mendaftar, dan biarkan produsen cukup berteriak “kejadian ini terjadi”.
SplSubject dan SplObserver Bawaan PHP #
PHP sudah menyediakan pasangan interface untuk pola ini di ekstensi SPL: SplSubject untuk pihak yang diamati, SplObserver untuk pendengarnya. Contoh berikut memakai keduanya untuk alur pendaftaran pengguna baru:
<?php
final class PenggunaBaru implements SplSubject
{
/** @var SplObjectStorage<SplObserver, null> */
private SplObjectStorage $pengamat;
public function __construct(private readonly string $nama)
{
$this->pengamat = new SplObjectStorage();
}
public function attach(SplObserver $pengamat): void
{
$this->pengamat->attach($pengamat);
}
public function detach(SplObserver $pengamat): void
{
$this->pengamat->detach($pengamat);
}
public function notify(): void
{
$this->pengamat->rewind();
while ($this->pengamat->valid()) {
$pengamat = $this->pengamat->current();
$this->pengamat->next();
$pengamat->update($this);
}
}
public function simpan(): void
{
echo "Menyimpan pengguna '{$this->nama}' ke database\n";
$this->notify();
}
public function nama(): string
{
return $this->nama;
}
}
final class KirimSelamatDatang implements SplObserver
{
public function update(SplSubject $subjek): void
{
if (!$subjek instanceof PenggunaBaru) {
return;
}
echo "[email] Selamat datang, {$subjek->nama()}!\n";
}
}
final class SiapkanKeranjang implements SplObserver
{
public function update(SplSubject $subjek): void
{
if (!$subjek instanceof PenggunaBaru) {
return;
}
echo "[keranjang] Keranjang kosong disiapkan untuk {$subjek->nama()}\n";
}
}
$pengguna = new PenggunaBaru("Budi");
$pengguna->attach(new KirimSelamatDatang());
$keranjang = new SiapkanKeranjang();
$pengguna->attach($keranjang);
$pengguna->simpan();
$pengguna->detach($keranjang);
$pengguna->simpan();Output yang Dihasilkan:
Menyimpan pengguna 'Budi' ke database
[email] Selamat datang, Budi!
[keranjang] Keranjang kosong disiapkan untuk Budi
Menyimpan pengguna 'Budi' ke database
[email] Selamat datang, Budi!Mari kita bedah kerjanya:
- Baris 3-43:
PenggunaBaruadalah subjek — pihak yang diamati. Ia mengimplementasiattach(),detach(), dannotify()sesuai kontrakSplSubject. - Baris 5-11: daftar pengamat disimpan dalam
SplObjectStorage, himpunan berkunci objek: meng-attachobjek yang sama dua kali tetap tercatat sekali, dan urutan pemanggilannya mengikuti urutan pendaftaran. - Baris 23-31:
notify()memajukan iterator sebelum memanggilupdate(). Pola ini membuat iterasi tetap aman ketika seorang pengamat melepaskan dirinya sendiri di tengah notifikasi — kejadian yang membuatforeachbiasa melewatkan elemen. - Baris 33-37:
simpan()adalah titik perubahan status: setelah data tersimpan,notify()mengabari semua pengamat bahwa sesuatu telah terjadi. - Baris 69-78: dua pengamat mendaftar lewat
attach(), peristiwasimpan()pertama memicu keduanya, laludetach($keranjang)mencabut satu pengamat — perhatikan bahwasimpan()kedua hanya memicu email. Berhenti berlangganan bekerja tanpa menyentuh satu baris pun di subjek maupun pengamat lain.
Dispatcher Event dengan Kontrak Buatan #
Untuk aplikasi yang bertumbuh, bentuk yang lebih lepas adalah event dispatcher: satu objek penengah yang menampung pendaftaran pendengar berdasarkan nama kejadian. Produsen kejadian hanya mengenal dispatcher, dan pendengar hanya mengenal objek event:
<?php
interface PendengarEvent
{
public function tangani(object $event): void;
}
final class DispatcherEvent
{
/** @var array<string, list<PendengarEvent>> */
private array $pendengar = [];
public function on(string $namaEvent, PendengarEvent $pendengar): void
{
$this->pendengar[$namaEvent][] = $pendengar;
}
public function off(string $namaEvent, PendengarEvent $pendengar): void
{
$this->pendengar[$namaEvent] = array_values(array_filter(
$this->pendengar[$namaEvent] ?? [],
fn (PendengarEvent $terdaftar): bool => $terdaftar !== $pendengar
));
}
public function kirim(object $event): void
{
$namaEvent = $event::class;
foreach ($this->pendengar[$namaEvent] ?? [] as $pendengar) {
$pendengar->tangani($event);
}
}
}
final class PesananDibuat
{
public function __construct(
public readonly string $nomor,
public readonly int $total,
) {
}
}
final class CatatRiwayat implements PendengarEvent
{
public function tangani(object $event): void
{
if (!$event instanceof PesananDibuat) {
return;
}
echo "[riwayat] Pesanan {$event->nomor} senilai Rp{$event->total} dicatat\n";
}
}
final class KurangiStok implements PendengarEvent
{
public function tangani(object $event): void
{
if (!$event instanceof PesananDibuat) {
return;
}
echo "[stok] Stok dikurangi untuk pesanan {$event->nomor}\n";
}
}
final class LayananCheckout
{
public function __construct(private readonly DispatcherEvent $dispatcher)
{
}
public function selesaikan(string $nomor, int $total): void
{
// ... validasi keranjang, menyimpan pesanan ...
$this->dispatcher->kirim(new PesananDibuat($nomor, $total));
}
}
$dispatcher = new DispatcherEvent();
$dispatcher->on(PesananDibuat::class, new CatatRiwayat());
$kurangiStok = new KurangiStok();
$dispatcher->on(PesananDibuat::class, $kurangiStok);
$checkout = new LayananCheckout($dispatcher);
$checkout->selesaikan("ORD-2041", 275000);
// Berhenti berlangganan, lalu pesanan berikutnya tanpa reaksi stok
$dispatcher->off(PesananDibuat::class, $kurangiStok);
$checkout->selesaikan("ORD-2042", 150000);Output yang Dihasilkan:
[riwayat] Pesanan ORD-2041 senilai Rp275000 dicatat
[stok] Stok dikurangi untuk pesanan ORD-2041
[riwayat] Pesanan ORD-2042 senilai Rp150000 dicatatSusunannya bekerja begini:
- Baris 3-6:
PendengarEventadalah kontrak satu method — cukup untuk dikatakan “saya bisa bereaksi terhadap sebuah event”. - Baris 8-34:
DispatcherEventadalah penengah.on()mendaftarkan pendengar di bawah nama event,off()mencabutnya lewat perbandingan identitas objek, dankirim()menyiarkan event kepada semua pendengar yang cocok. - Baris 28: nama event diambil dari
$event::class, jadi nama kejadian selalu selaras dengan nama class-nya — membaca ulang kode setelah enam bulan tetap jelas, dan penggantian nama class otomatis membawa serta nama eventnya. - Baris 36-43:
PesananDibuatadalah objek event — value object kecil ber-propertyreadonlyyang membawa data kejadian (nomor dan total) tanpa bisa diubah di tengah jalan. - Baris 69-81:
LayananCheckouthanya mengenal dispatcher. Ia tidak tahu email ada, tidak tahu stok ada — dan sebaliknya, pendengar baru bisa mendaftar tanpa layanan ini tersentuh. - Baris 83-94: dua pendengar terdaftar untuk
PesananDibuat, pesananORD-2041memicu keduanya, laluoff()mencabutKurangiStoksehingga pesananORD-2042hanya memicu riwayat.
Pengujian pola ini juga murah. Setiap pendengar bisa diuji sendiri dengan event buatan — (new KurangiStok())->tangani(new PesananDibuat("ORD-9999", 1000)) — tanpa dispatcher, tanpa checkout, tanpa database. Dispatcher sendiri cukup diuji dengan satu pendengar mata-mata yang mencatat pemanggilan, seperti PencatatMataMata di chapter Decorator Pattern
.
PSR-14: Standar Event Dispatcher #
Kebutuhan ini begitu umum sampai PHP-FIG menstandar lewat PSR-14. Intinya ringkas: satu interface EventDispatcherInterface dengan method dispatch(object $event): object, plus ListenerProviderInterface yang memetakan event ke pendengar, dan StoppableEventInterface yang membolehkan sebuah event menghentikan penyebarannya ke pendengar berikutnya. Implementasi populer seperti symfony/event-dispatcher dan league/event mengikuti standar ini, sehingga kode kita bisa berganti implementasi tanpa mengubah pemanggilan. Untuk aplikasi produksi, gunakan package tersebut; dispatcher buatan tadi pantas dipakai untuk memahami mesinnya, bukan untuk menyaingi yang sudah teruji.
Kapan Observer Menyulitkan #
Kekuatan observer — alur yang tidak tertulis — adalah juga kelemahan terbesarnya. Saat membaca selesaikan(), tidak ada petunjuk visual bahwa stok akan berkurang dan riwayat akan tercatat; stack trace dari sebuah exception di pendengar menunjukkan dispatcher, bukan siapa yang mendaftarkan pendengar tersebut. Urutan eksekusi mengikuti urutan pendaftaran yang sering kali tersebar di beberapa berkas, dan satu pendengar yang melempar exception akan menghentikan pendengar-pendengar setelahnya tanpa jejak yang jelas. Debugging alur lintas event dengan demikian menuntut kelengkapan pendaftaran dipahami lebih dulu.
Karena itu batas pemakaiannya perlu disiplin. Gunakan observer untuk reaksi pinggiran — notifikasi, pencatatan audit, invalidasi cache — yang memang boleh terlambat atau gagal tanpa merusak transaksi inti. Untuk langkah yang wajib berhasil sebagai bagian dari alur bisnis (misalnya pengurangan stok saat pesanan dibuat), panggilan eksplisit lewat service tetap lebih jujur untuk dibaca dan ditelusuri.
Ringkasan #
Observer Pattern memutus keterikutan produsen dan konsumen kejadian: subjek atau dispatcher menyiarkan event, pendengar mendaftar dan berhenti kapan saja, dan SplSubject/SplObserver memberi versi siap pakai sementara dispatcher buatan menawarkan kendali penuh atas nama event dan bentuk pendengar. Standar PSR-14 meneguhkan bentuk ini di ekosistem, dan disiplin memilih reaksi pinggiran menjaga alur inti tetap mudah ditelusuri. Pattern berikutnya, Repository, berpindah ke urusan yang berbeda: menyembunyikan cara aplikasi membaca dan menyimpan data di balik satu kontrak yang rapi.