🔬 BACOT — Sesi 15:00 · Technical Exploration
📚 Topik: Laravel 13 — Service Container, Service Provider, Queue Worker (satu pipeline, bukan tiga topik terpisah)
Versi acuan: Laravel 13.x (rilis 17 Maret 2026, PHP min 8.3, security fix sampai 17 Maret 2028) — laravel.com/framework/docs/releases. Rilis terbaru saat materi ini disusun (31 Okt 2026): 13.39 (28 Okt 2026). Rantai container→provider→worker berubah cukup banyak di 13.22–13.38, jadi contoh di bawah pakai API yang ada di 13.34+.
🎯 Tujuan Belajar
Bisa jawab 3 pertanyaan ini dengan pasti setelah sesi:
1. Kapan pakai singleton vs scoped vs bind — dan apa yang rusak kalau salah pilih di Octane / queue worker?
2. Kenapa logika harus di boot() bukan register() — dan kegagalan konkretnya seperti apa?
3. Kenapa job bisa diproses dua kali padahal $tries = 1 — dan angka mana yang mengaturnya?
🏗️ Arsitektur & Alur Teknis
Alur bootstrap (per request / per job)
index.php → bootstrap/app.php → Application::__construct()
→ registerBaseServiceProviders (Event, Log, Routing)
→ registerConfiguredProviders (bootstrap/providers.php + deferred)
├─ tahap 1: semua register() dipanggil berurutan
└─ tahap 2: semua boot() dipanggil berurutan
→ resolving target (controller/job) via container
Dua fase ini tidak boleh dicampur. Di register() kamu hanya boleh menulis ke container. Di boot() semua provider sudah terdaftar, jadi aman memakai service milik provider lain.
Alur satu job
dispatch(ProcessPodcast)
→ serialize payload (class name + public props) → push ke Redis/SQS/DB
→ worker: lpop → unserialize → container->make(JobClass) ← container dipakai di sini
→ job->handle(deps) ← dependency di-inject container, bukan dari payload
→ sukses: delete | gagal: release/fail
→ scoped instance di-flush di antara job (Laravel 11+ lifecycle)
Poin krusial: dependency job tidak di-serialize. Payload hanya berisi properti publik + class name. Kalau kamu menaruh service di constructor dengan SerializesModels, model di-reload dari DB saat worker jalan — ini sumber ModelNotFoundException klasik.
Tiga "hidup" objek di container
| Binding | Instance dipakai ulang | Umur |
|---|---|---|
bind() |
tidak | baru tiap make() |
singleton() |
ya | sampai proses mati (request, atau seluruh hidup worker) |
scoped() |
ya | sampai akhir request / akhir satu job |
scoped() = singleton yang di-flush per lifecycle. Di FPM biasa bedanya tipis. Di Octane dan queue worker bedanya fatal — worker hidup berjam-jam, singleton ikut menyimpan state job sebelumnya.
💻 Kode Nyata / Konfigurasi
1. Container — atribut, bukan binding manual
Laravel 12.21+ punya #[Singleton] / #[Scoped], 13.22+ punya #[BindWhen] untuk binding kondisional.
<?php
// app/Services/Report/ReportCache.php
namespace App\Services\Report;
use Illuminate\Container\Attributes\Scoped;
#[Scoped] // aman: di-flush per request & per job
class ReportCache
{
private array $memo = [];
public function remember(string $k, \Closure $fn): mixed
{
return $this->memo[$k] ??= $fn();
}
}
<?php
// app/Contracts/PaymentGateway.php
namespace App\Contracts;
use App\Services\Payments\BetaPaymentGateway;
use App\Services\Payments\FakePaymentGateway;
use App\Services\Payments\StripePaymentGateway;
use Illuminate\Container\Attributes\Bind;
use Illuminate\Container\Attributes\BindWhen;
#[BindWhen(BetaPaymentGateway::class, static function ($container) {
return $container->make('config')->get('features.payments.beta');
})]
#[Bind(FakePaymentGateway::class, environments: ['local', 'testing'])]
#[Bind(StripePaymentGateway::class)]
interface PaymentGateway {}
Catatan versi (DIKETAHUI, laravel-news 13.22): #[BindWhen] memakai closure di argumen atribut — itu fitur PHP 8.5 (rilis 20 Nov 2025). Di PHP 8.3/8.4 atribut ini gagal parse. Kalau proyek masih 8.3, pakai $this->app->bind() biasa.
Binding kondisional tanpa atribut, versi portable:
// app/Providers/PaymentServiceProvider.php
public function register(): void
{
$this->app->bind(PaymentGateway::class, function ($app) {
return match (true) {
$app['config']->get('features.payments.beta') => new BetaPaymentGateway(),
$app->environment('local', 'testing') => new FakePaymentGateway(),
default => new StripePaymentGateway(),
};
});
}
Contextual attribute — inject nilai spesifik tanpa binding manual (tersedia sejak 11.x):
public function __construct(
#[Storage('s3')] protected Filesystem $disk,
#[Config('services.stripe.secret')] protected string $secret,
#[DB('reporting')] protected Connection $db,
) {}
Daftar atribut kontekstual di 13.x: Auth, Cache, Config, Context, DB, Give, Log, RequestAttribute, RouteParameter, Storage, Tag, CurrentUser.
2. Provider — pembagian kerja yang tidak bisa ditawar
<?php
// app/Providers/ReportServiceProvider.php
namespace App\Providers;
use App\Contracts\ReportWriter;
use App\Services\Report\S3ReportWriter;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
class ReportServiceProvider extends ServiceProvider
{
public array $singletons = [
ReportWriter::class => S3ReportWriter::class,
];
// HANYA container.
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../../config/report.php', 'report');
}
// Semua provider sudah terdaftar. Aman sentuh Queue, Event, Route, Blade.
public function boot(): void
{
Queue::route(\App\Jobs\BuildReport::class, connection: 'redis', queue: 'reports');
Queue::route(\App\Concerns\RequiresHeavyIo::class, queue: 'heavy');
}
}
Queue::route() adalah fitur Laravel 13 — routing berdasarkan kelas/interface/trait/parent class, bukan per-job onQueue(). Ini yang bikin onQueue() tersebar di 40 job bisa dihapus dan diganti satu blok di provider.
Untuk provider berat, tandai deferred supaya tidak dimuat tiap request:
class ReportServiceProvider extends ServiceProvider
{
protected bool $defer = true;
public function provides(): array { return [ReportWriter::class]; }
}
3. Queue worker — atribut, bukan anotasi docblock
Laravel 13 memindahkan mayoritas kontrol job ke PHP attribute:
<?php
// app/Jobs/BuildReport.php
namespace App\Jobs;
use App\Models\Report;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\{
Backoff, CountCrashesAsExceptions, FailOnTimeout, MaxExceptions, Timeout, Tries
};
use Illuminate\Queue\SerializesModels;
#[Tries(3)]
#[MaxExceptions(3)]
#[Backoff(10)]
#[Timeout(120)]
#[FailOnTimeout]
#[CountCrashesAsExceptions] // 13.34+, dan 13.38+ juga jalan di listener/mailable/notification
class BuildReport implements ShouldQueue, Interruptible
{
use Queueable, SerializesModels;
public function __construct(public Report $report) {}
public function handle(\App\Services\Report\ReportCache $cache): void
{
$cache->remember($this->report->id, fn () => $this->build());
}
public function interrupted(int $signal): void
{
if ($signal === SIGALRM) { // 13.34+: timeout kirim SIGALRM, bukan SIGKILL buta
$this->saveProgress();
}
}
}
Interruptible + SIGALRM = satu-satunya cara menyimpan progress sebelum worker menandai gagal. Sebelum 13.34, timeout mematikan worker tanpa memanggil job sama sekali.
Konfigurasi koneksi Redis — dua angka yang menentukan hidup-mati:
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90), // default 90 dtk
'block_for' => 5, // null = polling; 5 = BRPOP blocking, latency & CPU lebih baik
'after_commit' => false,
],
# timeout HARUS beberapa detik di bawah retry_after
php artisan queue:work redis --queue=reports,default --tries=3 --timeout=80 --memory=60%
--memory=60% baru ada di 13.35 (PR #61753). Dengan memory_limit=512M, worker berhenti setelah satu job selesai begitu pemakaian ~307 MB. Kalau memory_limit=-1 (umum di CLI), persentase jadi negatif dan worker tidak pernah berhenti karena memori — set memory_limit eksplisit di supervisor.
Supervisor (produksi):
[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --queue=reports,default --sleep=1 --tries=3 --timeout=80 --max-time=3600 --memory=60%
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=90
stdout_logfile=/var/log/worker.log
--max-time=3600 = worker keluar sendiri tiap jam, supervisor menyalakan ulang. Ini mitigasi memory leak paling murah, lebih andal daripada berharap tidak ada kebocoran.
📊 Angka & Benchmark
| Angka | Nilai | Sumber |
|---|---|---|
retry_after default Redis |
90 detik | config Laravel 13 docs/queues |
Jarak aman --timeout vs retry_after |
minimal beberapa detik lebih pendek | docs/queues — "job expirations and timeouts" |
--memory=60% × memory_limit=512M |
worker stop di ~307 MB | laravel-news 13.35 (7 Okt 2026) |
block_for => 5 |
BRPOP blocking 5 dtk, ganti busy-poll | docs/queues |
lazy() / lazyById() peak memory |
turun ~2× (satu chunk, bukan dua, ditahan) | 13.38, PR #62086 — tanpa benchmark publik, hanya deskripsi perubahan |
| Dukungan versi | 13.x: PHP 8.3–8.5, rilis 17 Mar 2026, security s.d. 17 Mar 2028 | laravel.com/framework/docs/releases |
⚠️ Tidak ada benchmark throughput resmi Laravel untuk kombinasi block_for/numprocs. Angka throughput harus diukur sendiri (wrk/k6 untuk HTTP, hitung jobs per second dari queue:monitor atau tabel failed_jobs), jangan dikutip dari blog.
⚖️ Trade-off & Alternatif
| Keputusan | Pilih ini kalau | Jangan kalau |
|---|---|---|
singleton |
objek immutable, client HTTP, konfigurasi | menyimpan state per-request → bocor antar job |
scoped |
cache/memo per request, tenant context | butuh state lintas request (jarang valid) |
bind |
objek murah, stateful per pemakaian | dipanggil 200× per request → refleksi berulang |
Atribut (#[Scoped], #[BindWhen]) |
13.x, PHP 8.5, ingin config menempel di kelas | PHP 8.3/8.4 → #[BindWhen] gagal parse |
Queue::route() |
routing terpusat, job di banyak paket | butuh override per-dispatch → tetap pakai onQueue() |
Redis + block_for |
butuh latensi rendah, volume tinggi | Redis belum ada / butuh durability kuat → SQS/DB |
database queue |
volume rendah, tak mau tambah infra | > ~50 job/dtk → lock contention di tabel jobs |
| Horizon | Redis, butuh dashboard + auto-scaling worker | pakai SQS/DB → Horizon tidak mendukung |
| Sync driver | test | produksi — request langsung menanggung beban job |
🏭 Use Case Produksi & Failure Mode Umum
1. Job diproses dua kali padahal $tries=1
--timeout=120 sementara retry_after=90. Di detik 90 job dikembalikan ke queue, worker kedua mengambilnya, sementara worker pertama masih jalan. Dua eksekusi paralel. Fix: timeout < retry_after. Ini penyebab paling sering, dan paling sulit didiagnosis karena tidak ada error sama sekali.
2. Singleton menyimpan state job sebelumnya
Worker hidup 6 jam. ReportCache di-bind singleton → memo job A terbawa ke job B milik tenant lain. Gejala: data salah milik, tidak reproducible di lokal (karena di lokal tiap request proses baru). Fix: #[Scoped].
3. Logika di register() → service belum siap
Memanggil Queue::route() atau Event::listen() di register() provider yang dimuat sebelum Queue provider terdaftar. Kadang jalan, kadang tidak — tergantung urutan bootstrap/providers.php. Ini bug yang muncul setelah menambah provider baru.
4. Sticky connection bocor antar job (13.36, PR #61915)
Dengan sticky => true pada read/write split, satu job yang menulis membuat semua job berikutnya di worker yang sama membaca dari write connection. Throughput replica hilang diam-diam. Fix:
// AppServiceProvider::boot()
\Illuminate\Queue\Worker::$flushStickyConnections = true; // opt-in, default false
Default false karena job bisa bergantung pada tulisan job sebelumnya. Kenali trade-off-nya.
5. Worker crash tidak dihitung sebagai exception (13.34, PR #61737)
Job dengan retryUntil() dan rate limiter tapi tanpa $tries bisa membunuh worker berulang (OOM, container stop) sampai deadline lewat — tanpa pernah masuk failed_jobs. #[CountCrashesAsExceptions] membuat crash dihitung satu exception. Biaya: 2 cache call per attempt. Makanya opt-in.
6. Deploy tidak me-restart worker Worker memuat kode dari memori. Setelah deploy, job lama tetap dieksekusi kode lama. Wajib di pipeline deploy:
php artisan queue:restart
dan stopwaitsecs supervisor ≥ timeout.
7. after_commit salah set
after_commit => false + dispatch di dalam DB::transaction() → worker bisa mengambil job sebelum commit, model belum ada, ModelNotFoundException. Alternatif lebih presisi per-job: ->afterCommit() atau dispatchAfterResponse.
8. Stale read antar replika (13.38)
User simpan lalu reload, lihat data lama. Dua API baru: middleware PreventStaleDatabaseReads (2 detik window per user) dan DB::usingWritePdo(fn () => ...). Keduanya opt-in, keduanya hanya melindungi user yang menulis — user lain tetap bisa baca replika yang tertinggal.
🛠️ Latihan Praktis Kecil (~30 menit, laptop sendiri)
composer create-project laravel/laravel ctr-demo "^13.0"
cd ctr-demo
php artisan make:provider DemoServiceProvider
Isi app/Providers/DemoServiceProvider.php:
<?php
namespace App\Providers;
use Illuminate\Container\Attributes\Scoped;
use Illuminate\Support\ServiceProvider;
#[Scoped]
class Counter { public int $n = 0; }
class DemoServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('demo.singleton', fn () => new Counter);
// 'demo.scoped' di-resolve via atribut; bind manual untuk demo:
$this->app->scoped('demo.scoped', fn () => new Counter);
}
public function boot(): void
{
// buktikan boot() jalan setelah semua register()
logger('boot: queue manager ready = ' . (app()->bound('queue') ? 'yes' : 'no'));
}
}
Daftarkan di bootstrap/providers.php, lalu:
php artisan tinker --execute='
$s = app("demo.singleton"); $s->n = 42;
$c = app("demo.scoped"); $c->n = 42;
echo "singleton sama? " . var_export($s === app("demo.singleton"), true) . PHP_EOL;
echo "scoped sama? " . var_export($c === app("demo.scoped"), true) . PHP_EOL;
assert($s === app("demo.singleton")); // harus true
// Untuk melihat scoped di-flush: butuh boundary request/job, lihat langkah berikut.
echo "OK" . PHP_EOL;
'
Buktikan scoped di-flush per job:
php artisan make:job ProbeScoped
public int $id;
public function __construct(int $id = 0) { $this->id = $id; }
public function handle(): void
{
$c = app('demo.scoped');
logger("job {$this->id} lihat n={$c->n}");
$c->n++; // kalau bocor, job 2 lihat n=1
}
php artisan migrate --force
php artisan queue:work --once --tries=1 & # atau dispatch 3 job lalu jalankan worker
php artisan tinker --execute='foreach ([1,2,3] as $i) dispatch(new App\Jobs\ProbeScoped($i));'
php artisan queue:work --stop-when-empty
grep "lihat n=" storage/logs/laravel.log
Harapan: ketiganya n=0. Kalau n=1,2 → binding-nya singleton, bukan scoped.
Latihan tambahan: ubah timeout/retry_after jadi timeout=95, retry_after=90, buat job sleep(93), lalu amati job muncul dua kali di log. Itu failure mode nomor 1 di atas, dibuktikan sendiri dalam 2 menit.
✅ Cek Pemahaman
- Sebuah job
sleep(100),retry_after=90,--timeout=120,$tries=1. Apa yang terjadi pada eksekusi job itu, dan berapa kalihandle()berjalan? - Kamu punya
TenantContext(berisitenant_idhasil resolve dari request). Kenapasingletonberbahaya di Octane dan di queue worker, dan kenapascopedcukup? Queue::route(ShouldBroadcast::class, queue: 'events')diletakkan diregister()sebuah provider yang terdaftar sebelumBroadcastServiceProvider. Apa gejala yang muncul, dan kenapa?
Kunci ringkas
1. `handle()` berjalan **dua kali**. Timeout 120 > retry_after 90, jadi job dikembalikan ke queue di detik 90 saat worker pertama masih mengeksekusi. `$tries=1` tidak menolong karena percobaan kedua belum tercatat gagal. 2. Keduanya long-running process. Singleton menyimpan `tenant_id` tenant pertama dan terbawa ke request/job tenant berikutnya → kebocoran data lintas tenant. `scoped` di-flush di batas request/job, jadi umurnya tepat satu unit kerja. 3. Routing tidak konsisten/absen — job kadang masuk queue default karena `ShouldBroadcast` interface atau Queue manager belum terdaftar saat `register()` dieksekusi. Urutan provider bukan kontrak; `boot()` adalah tempat yang dijamin.🔑 Poin Kunci
register()= container saja.boot()= semua provider sudah ada. Melanggar ini menghasilkan bug yang bergantung urutan provider.singletondi queue worker = state bocor antar job. Default untuk service stateful:scoped.timeoutharus lebih pendek dariretry_after. Selisihnya = pengaman dari eksekusi ganda.- Laravel 13 memindahkan kontrol job ke attribute (
#[Tries],#[Backoff],#[Timeout],#[MaxExceptions],#[FailOnTimeout],#[CountCrashesAsExceptions]). Docblock$triesmasih jalan, atribut lebih jujur karena ikut ke-refleksi. Queue::route()(13.x) = routing terpusat per class/interface/trait, override masih mungkin per-dispatch.--memory=60%(13.35) baru berguna kalaumemory_limitdiset;-1membuat persentase tak berefek.- Crash worker ≠ exception. Tanpa
#[CountCrashesAsExceptions], OOM tidak dihitung kemaxExceptions. php artisan queue:restartwajib ada di setiap deploy. Tanpa itu, kode lama tetap jalan.
➡️ Eksplorasi Lanjut
- Horizon vs
queue:workpolos — auto-balancing,nice, timeout per-supervisor. Kapan Horizon tidak layak (SQS/DB driver tidak didukung). after_commitvsDB::afterCommitvsdispatchAfterResponse— tiga jaminan berbeda, sering dikira sama.- Octane + container —
scopedflush,Application::flush(), bahaya static property di package pihak ketiga. - Job batching (
Bus::batch) dan#[Delay]di batch/bulk (fix di 13.22, PR #60766). defer()/ deferred provider — mengukur dampak boot time pada aplikasi dengan 40+ provider.- Laravel Pulse untuk observability queue tanpa menambah APM berbayar; bandingkan dengan
queue:monitor.
⚠️ Batas verifikasi: semua klaim versi/fitur di atas bersumber dari laravel.com/framework/docs (13.x) dan laravel-news.com per 31 Oktober 2026. Tidak ada benchmark throughput resmi yang dipublikasikan — angka performa di bagian mana pun materi ini yang tidak menyebut sumber adalah inferensi, bukan hasil ukur.