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:
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:
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:
middleware(): array— job middleware menerima job dan callback$next, sama seperti route middleware. ClassIlluminate\Queue\Middleware\RateLimitedbawaan framework sudah berisi logika throttle lengkap.$tries = 10— setiap kali job dirilis karena rate limit, hitunganattemptstetap bertambah. Nilai$triesyang lebih longgar (atauretryUntil()) 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:
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:
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: 0Hasilnya 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:
[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:
releaseAfter: delay_rilis=5 detik, attempts=1, terkirim=2Angka 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:
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:
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 .