← semua materi
15:00 Teknis · 31 Oct 2026 · 15:03

🔬 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

  1. Sebuah job sleep(100), retry_after=90, --timeout=120, $tries=1. Apa yang terjadi pada eksekusi job itu, dan berapa kali handle() berjalan?
  2. Kamu punya TenantContext (berisi tenant_id hasil resolve dari request). Kenapa singleton berbahaya di Octane dan di queue worker, dan kenapa scoped cukup?
  3. Queue::route(ShouldBroadcast::class, queue: 'events') diletakkan di register() sebuah provider yang terdaftar sebelum BroadcastServiceProvider. 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


➡️ Eksplorasi Lanjut

  1. Horizon vs queue:work polos — auto-balancing, nice, timeout per-supervisor. Kapan Horizon tidak layak (SQS/DB driver tidak didukung).
  2. after_commit vs DB::afterCommit vs dispatchAfterResponse — tiga jaminan berbeda, sering dikira sama.
  3. Octane + container — scoped flush, Application::flush(), bahaya static property di package pihak ketiga.
  4. Job batching (Bus::batch) dan #[Delay] di batch/bulk (fix di 13.22, PR #60766).
  5. defer() / deferred provider — mengukur dampak boot time pada aplikasi dengan 40+ provider.
  6. 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.