Pojedyncze zadanie w kolejce to dopiero początek. Prawdziwe systemy potrzebują trzech wzorców: batchingu (setki zadań z jednym zbiorczym wynikiem — importy, kampanie), łańcuchów (zadania wykonywane po kolei, gdzie każde dostaje wynik poprzedniego — faktura → PDF → mail) i rate limitingu (ochrona zewnętrznych API przed zalaniem). Ten poradnik pokazuje wszystkie trzy z produkcyjnymi przykładami.
Najważniejsze: Najważniejsze —
Bus::batch()grupuje zadania i daje jedno powiadomienie o zakończeniu całości plus postęp ($batch->progress()).Bus::chain()wykonuje zadania sekwencyjnie i przerywa łańcuch na pierwszym błędzie. Rate limiting przez middlewareRateLimitedchroni zewnętrzne API — bez niego 10 workerów wyśle 10 równoległych żądań i dostanie 429.
Job batching — setki zadań, jeden wynik
Import 10 000 wierszy CSV jako 10 000 osobnych zadań?
Tak, i to jest właściwe podejście — ale potrzebujesz wiedzieć, kiedy import się skończył i czy coś się nie powiodło. Batch rozwiązuje oba problemy:
use App\Jobs\ImportRow;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch(
$rows->map(fn ($row) => new ImportRow($row))
)->then(function (Batch $batch) {
// wszystkie zadania zakończone sukcesem
Notification::send($user, new ImportCompleted($batch->id));
})->catch(function (Batch $batch, Throwable $e) {
// pierwszy błąd w batchu — tu logujesz i alertujesz
Log::error('Import failed', ['batch' => $batch->id, 'error' => $e->getMessage()]);
})->finally(function (Batch $batch) {
// zawsze — sukces czy błąd
Cache::forget('import-running');
})->name('Import produktów')
->allowFailures() // jeden błąd nie zatrzymuje całego batcha
->dispatch();
Kluczowe właściwości batcha
| Metoda / właściwość | Znaczenie |
|---|---|
then() |
Callback po sukcesie całego batcha |
catch() |
Callback przy pierwszym błędzie |
finally() |
Zawsze na końcu (sukces lub błąd) |
allowFailures() |
Błędy pojedynczych zadań nie przerywają batcha |
$batch->progress() |
Procent ukończenia (0–100) |
$batch->processedJobs() |
Liczba przetworzonych zadań |
$batch->failedJobs |
Liczba nieudanych |
$batch->cancel() |
Anulowanie batcha w trakcie |
Postęp pokazujesz użytkownikowi w interfejsie — pobierasz batch po ID i renderujesz pasek
$batch = Bus::findBatch($batchId);
return [
'progress' => $batch->progress(), // 0-100
'processed' => $batch->processedJobs(), // 342
'total' => $batch->totalJobs, // 1000
'failed' => $batch->failedJobs, // 0
];
Gdzie trafiają batche w bazie? Tabele job_batches — wymaga migracji php artisan make:queue-batches-table. Bez niej batch rzuci wyjątek przy dispatchu. Warto też pamiętać, że batch działa najlepiej na Redisie: sterownik database obsługuje batche, ale pod obciążeniem kolejkuje wolniej.
Kiedy batch zamiast pętli dispatchów? Zawsze, gdy potrzebujesz wiedzieć, że „całość się skończyła" — importy, masowe aktualizacje cen, wysyłka kampanii, przetwarzanie plików. Pętla foreach { dispatch() } nie daje żadnej z tych informacji.
Job chains — sekwencje z przekazywaniem wyniku
Chain wykonuje zadania po kolei, a każde następne może dostać wynik poprzedniego. Typowy scenariusz fakturowy
use App\Jobs\{FetchInvoice, GenerateInvoicePdf, SendInvoiceEmail};
use Illuminate\Support\Facades\Bus;
Bus::chain([
new FetchInvoice($order),
new GenerateInvoicePdf($order),
new SendInvoiceEmail($order),
])->catch(function (Throwable $e) {
Log::error('Invoice chain failed', ['error' => $e->getMessage()]);
})->dispatch();
Trzy właściwości łańcucha, które trzeba znać
- Przerwanie na pierwszym błędzie — jeśli
GenerateInvoicePdfrzuci wyjątek,SendInvoiceEmailsię nie wykona. Nie wyślesz maila z PDF-em, którego nie ma. To najczęściej pożądane zachowanie. - Współdzielony kontekst — kolejne zadania dostają wynik poprzedniego przez parametr konstruktora; łańcuch przekazuje obiekt dalej (wzorzec „przekaż wynik dalej").
- Jeden
catch()dla całego łańcucha — obsługujesz błąd na końcu, nie w każdym zadaniu osobno.
Chain i batch można łączyć: batch, którego zadania to łańcuchy (Bus::batch([...])->then(...) z chainami w środku) — ale zanim to zrobisz, zapytaj, czy problem nie jest prostszy. Dwa poziomy zagnieżdżenia to zwykle sygnał, że proces warto rozbić na mniejsze.
Rate limiting — ochrona zewnętrznych API
- Scenariusz: 10 workerów, każdy przetwarza zadania, które wołają API partnera z limitem 30 żądań na minutę. Bez ograniczenia 10 procesów wyśle 10 równoległych żądań w tej samej sekundzie, partner odpowie 429, zadania wejdą w retry i po kilku minutach cała kolejka jest zajęta przez zadania, które i tak nie mogą się wykonać. Klasyczna kaskada awarii.
Rozwiązanie: middleware RateLimited
use Illuminate\Queue\Middleware\RateLimited;
class SyncProductToPartner implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('partner-api')];
}
}
// AppServiceProvider::boot() — definicja limitu
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('partner-api', function (object $job) {
return Limit::perMinute(30)->by('partner-api');
});
- Co się dzieje: zadanie, które nie dostanie „przepustki" w limicie, wraca do kolejki z opóźnieniem i spróbuje później — bez zużywania próby retry i bez błędu. Praca nie ginie, tylko czeka na swoją kolej.
Można też różnicować limit per partner (klucz po ID klienta), gdy każdy klient ma własny limit
RateLimiter::for('partner-api', function (object $job) {
return Limit::perMinute(30)->by($job->partnerId);
});
Release i opóźnienia — kontrola nad zwracaniem zadania
Zadanie odrzucone przez limiter wraca do kolejki domyślnie po ~60 sekundach. Możesz to kontrolować i różnicować zachowanie:
public function middleware(): array
{
return [
(new RateLimited('partner-api'))->releaseAfter(30), // wróć po 30 s
// albo: ->dontRelease() — zadanie NIE wraca do kolejki (rzuca wyjątek i idzie do failed_jobs)
];
}
dontRelease() przydaje się, gdy przekroczenie limitu oznacza błąd konfiguracji, a nie chwilowy szczyt — chcesz wtedy głośny błąd w failed_jobs, a nie ciche krążenie zadania. Domyślne „release" (zwróć i spróbuj później) jest właściwe dla chwilowych szczytów ruchu.
W komendach i kodzie poza kolejką ten sam mechanizm obsługuje Redis::throttle() — fluentny builder limitu używany wewnątrz zadań lub komend:
Redis::throttle('partner-api')->allow(30)->every(60)->then(
fn () => $this->syncWithPartner(),
fn () => $this->release(60), // limit wyczerpany — zwróć zadanie
);
W praktyce 90% przypadków pokrywa RateLimited z named limiterem — po Redis::throttle() sięgaj, gdy potrzebujesz limitu ad-hoc bez definiowania go w providerze.
Uwaga: Rate limiting bez
allowFailures()albo bez obsługi „zwróconego" zadania potrafi zamaskować problem: kolejka rośnie, zadania krążą, a w logach nie ma błędu. Monitoruj długość kolejki (Horizon) i czas oczekiwania — jeśli rośnie mimo sprawnych workerów, sprawdź, czy limiter nie dławi ruchu bardziej, niż zakładałeś.
Który wzorzec wybrać — drzewko decyzyjne
| Potrzebujesz… | Wzorzec |
|---|---|
| Setki niezależnych zadań + wiedzieć, kiedy całość się skończyła | Batch |
| Zadania wykonywane po kolei, zależne od siebie | Chain |
| Ochrony zewnętrznego API przed zalaniem | Rate limiting |
| Jednego zadania wykonanego w tle | Zwykły dispatch() |
| Powtarzania zadania w interwale | Scheduler + Schedule::job() |
Kolejność wdrażania w istniejącym projekcie: najpierw rate limiting (jeśli są zewnętrzne API — najczęstsze źródło awarii), potem batch dla procesów masowych, na końcu chainy dla wieloetapowych przepływów.
Testowanie batchy, chainów i limitów
Dyspozycję sprawdzasz przez Bus::fake()
Bus::fake();
$this->post('/imports', ['file' => $file]);
Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 100);
Bus::assertChained([FetchInvoice::class, GenerateInvoicePdf::class]);
Limity testujesz trudniej (bo działają w warstwie kolejki) — najczęściej testem integracyjnym ze sterownikiem sync albo weryfikacją konfiguracji limitera. Szerzej o testowaniu kolejek piszę w poradniku Testowanie kolejek i jobów w Laravel.
Najczęściej zadawane pytania
Kiedy użyć batcha zamiast pętli dispatch()?
Gdy potrzebujesz wiedzieć, że całość się skończyła (powiadomienie, dalszy krok procesu) albo pokazać użytkownikowi postęp. Pętla foreach { dispatch() } nie daje żadnej z tych informacji. Batch dodaje też allowFailures(), cancel() i zbiorczą obsługę błędów.
Co się dzieje z łańcuchem, gdy jedno zadanie się nie powiedzie?
Łańcuch przerywa się na pierwszym błędzie — kolejne zadania się nie wykonają, a błąd trafia do catch() łańcucha (jeśli zdefiniowany) i do tabeli failed_jobs. To zachowanie chroni przed częściowymi stanami: nie wyślesz maila z fakturą, której nie udało się wygenerować.
Jak działa rate limiting w kolejkach Laravel?
Middleware RateLimited sprawdza named limiter (zdefiniowany w AppServiceProvider) przed wykonaniem zadania. Jeśli limit jest wyczerpany, zadanie wraca do kolejki z opóźnieniem — bez zużywania próby retry i bez błędu. Praca czeka, aż limit się „odnowi", a nie przepada.
Czy batch i chain można łączyć?
Technicznie tak — batch zadań, z których każde jest łańcuchem. W praktyce dwa poziomy zagnieżdżenia trudno się debuguje i rzadko jest potrzebne. Jeśli proces wymaga takiej struktury, rozważ rozbicie go na dwa etapy: batch kończy się, a jego callback dispatchuje kolejny etap.
Ile kosztuje wdrożenie batchingu w istniejącym projekcie?
Samo dodanie batchy to zwykle 1–2 dni pracy: migracja tabeli job_batches, refaktor pętli dispatchów na Bus::batch(), callbacki i podgląd postępu w UI. Największy koszt to zwykle warstwa interfejsu (pasek postępu, obsługa błędów częściowych), nie sam mechanizm kolejki.
👉 Importy blokują aplikację, a integracje dostają 429? Batching i rate limiting rozwiązują oba problemy. Opisz projekt na kontakt@informatix.com.pl lub przez formularz — wycena jest bezpłatna.
📖 Zobacz również: Kolejki w Laravel i Laravel Horizon — poradnik, testowanie kolejek, integracje API i webhooki oraz usługa Laravel development.