Worker queue tidak mengenal kata lelah — begitu ada 10.000 job di antrean, worker akan mengeksekusinya secebar mungkin. Masalahnya, pihak ketiga yang kita panggil punya batas: API analytics yang hanya menerima 2 request per menit per akun akan membalas 429 Too Many Requests atau memblokir API key kita sama sekali. Job seperti ini sah untuk dijalankan, tetapi lajunya harus dibatasi. Laravel menyediakan middleware RateLimited untuk persis kebutuhan ini: job yang melampaui batas dirilis kembali ke antrean dengan delay yang pas, lalu dieksekusi begitu jendela kuota terbuka.

RateLimiter yang Sama untuk HTTP dan Job #

Batas laju kita definisikan lewat RateLimiter::for() di method boot() AppServiceProvider — pola yang sama dengan rate limiter route:

php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('analytics-api', function (object $job) {
    return Limit::perMinute(2)->by($job->userId);
});

Callback menerima objek job yang sedang diproses, sehingga by($job->userId) membuat bucket terpisah per user: user lain tidak ikut terhukum ketika satu user sedang menabrak batas. Karena limiter ini bernama, definisi yang sama bisa dipakai route HTTP dengan ->middleware('throttle:analytics-api') — satu tempat kebijakan, dua pemakai.

Menempelkan Middleware ke Job #

Job mengambil middleware lewat method middleware() — method ini tidak tersedia dari scaffold make:job, jadi kita tambahkan manual:

php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;

class SendAnalyticsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 10;

    public function __construct(public int $userId) {}

    public function middleware(): array
    {
        return [(new RateLimited('analytics-api'))];
    }

    public function handle(): void
    {
        Cache::increment('analytics_terkirim:'.$this->userId);

        logger()->info("Analytics user {$this->userId} terkirim.");
    }
}

Bagian pentingnya:

  1. middleware(): array — job middleware menerima job dan callback $next, sama seperti route middleware. Class Illuminate\Queue\Middleware\RateLimited bawaan framework sudah berisi logika throttle lengkap.
  2. $tries = 10 — setiap kali job dirilis karena rate limit, hitungan attempts tetap bertambah. Nilai $tries yang lebih longgar (atau retryUntil()) mencegah job gagal permanen hanya karena sering ditahan middleware.

Urutan Throttled yang Nyata #

Runner berikut mendispatch empat job untuk user 1 dan satu job untuk user 2, lalu menjalankan worker dua kali dengan jeda menunggu jendela 60 detit terbuka kembali:

php
use App\Jobs\SendAnalyticsJob;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

foreach (range(1, 4) as $i) {
    dispatch(new SendAnalyticsJob(1));
}
dispatch(new SendAnalyticsJob(2));

Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 20]);

echo 'setelah_worker1: terkirim_user1='.(int) Cache::get('analytics_terkirim:1')
    .' terkirim_user2='.(int) Cache::get('analytics_terkirim:2')."\n";
$job = DB::table('jobs')->first();
echo 'job_tertunda_delay: '.($job->available_at - time())." detik\n";
echo 'job_tertunda_attempts: '.$job->attempts."\n";

sleep(66); // tunggu jendela rate limit terbuka

Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 20]);

echo 'setelah_worker2: terkirim_user1='.(int) Cache::get('analytics_terkirim:1')
    .' terkirim_user2='.(int) Cache::get('analytics_terkirim:2')."\n";
echo 'jobs_tersisa: '.DB::table('jobs')->count()."\n";
echo 'failed_jobs: '.DB::table('failed_jobs')->count()."\n";

Output yang Dihasilkan:

text
setelah_worker1: terkirim_user1=2 terkirim_user2=1
job_tertunda_delay: 60 detik
job_tertunda_attempts: 1
setelah_worker2: terkirim_user1=4 terkirim_user2=1
jobs_tersisa: 0
failed_jobs: 0

Hasilnya menjawab tiga pertanyaan sekaligus: hanya 2 job user 1 yang lolos di menit pertama, job user 2 langsung jalan (bucket terpisah), dan 2 job tertahan menunggu lewat sleep(66) sebelum dieksekusi worker kedua. Log memperkuatnya — tiga baris pertama tercatat pada 20:33:57, dua baris terakhir pada 20:35:06:

text
[2026-10-13 20:33:57] local.INFO: Analytics user 1 terkirim.
[2026-10-13 20:33:57] local.INFO: Analytics user 1 terkirim.
[2026-10-13 20:33:57] local.INFO: Analytics user 2 terkirim.
[2026-10-13 20:35:06] local.INFO: Analytics user 1 terkirim.
[2026-10-13 20:35:06] local.INFO: Analytics user 1 terkirim.

Mekanika Rilis: delay, attempts, dan dontRelease #

Secara default middleware merilis job dengan delay sisa waktu jendela + 3 detik — itulah asal angka 60 detik di atas. Dua method mengubah perilaku itu. Pertama, releaseAfter() memaksa delay yang kita tentukan sendiri; job dengan (new RateLimited('analytics-api'))->releaseAfter(5) pada jendela penuh menghasilkan:

text
releaseAfter: delay_rilis=5 detik, attempts=1, terkirim=2

Angka delay_rilis dihitung dari available_at - created_at baris job yang dirilis — persis 5 detik. Kedua, dontRelease() membuat job yang tertahan dibuang: tidak dieksekusi, tidak diulang. Job dengan middleware itu pada jendela penuh menghasilkan terkirim=2 jobs_tersisa=0 failed=0 — job ketiga menguap tanpa jejak di failed_jobs.

Menguji dengan travel() #

Menunggu 60 detik nyata di test sungguh membuang waktu. Helper travel() memajukan waktu Carbon yang juga dipakai rate limiter (cache) dan perhitungan available_at, sehingga siklus rilis–lanjut bisa diuji seketika:

php
public function test_job_dirilis_saat_rate_limited_dan_lanjut_setelah_travel(): void
{
    foreach (range(1, 4) as $i) {
        dispatch(new SendAnalyticsJob(1));
    }

    Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 10]);

    fwrite(STDERR, 'terkirim_sebelum_travel: '
        .(int) Cache::get('analytics_terkirim:1')."\n");
    fwrite(STDERR, 'jobs_ditunda: '.DB::table('jobs')->count()."\n");

    $this->travel(70)->seconds();

    Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 20]);

    fwrite(STDERR, 'terkirim_sesudah_travel: '
        .(int) Cache::get('analytics_terkirim:1')."\n");
}

Output yang Dihasilkan:

text
terkirim_sebelum_travel: 2
jobs_ditunda: 2
terkirim_sesudah_travel: 4
rate_limited_travel_test: lulus

  PASS  Tests\Feature\RateLimitedJobTest
  ✓  job dirilis saat rate limited dan lanjut setelah travel    6.16s

  Tests:    1 passed (5 assertions)

Laju eksekusi kini terkendali. Satu pertanyaan terakhir menyusun section ini: bagaimana memastikan job benar-benar masuk antrean yang tepat — tanpa menjalankan worker sama sekali? Jawabannya adalah faking, dibahas tuntas di chapter Pengujian Queue . Rincian rate limiter lainnya ada di dokumentasi Rate Limiters dan dokumentasi Queues .