Laravel Queues Deep Dive: Jobs, Batches, Chains & Failed Jobs
TL;DR: I'm going to walk through queues the way I actually use them — not with a toy podcast-processing example, but with real jobs pulled from a ride-hailing platform I built, patterns intact, identifying details stripped. You'll see a job that reschedules itself instead of relying on cron, a hard rule about why you can never just delete a job class, and the honest tradeoff between catching an error and letting it blow up the retry. Then Laravel 13's new attribute syntax, applied to the exact same real jobs.
The Job That Started It All
Here's a real job from a ride-booking platform I built. When a rider schedules a ride for later, at the scheduled time something has to actually kick off driver search — and it has to handle the case where the rider cancelled in the meantime:
<?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\SerializesModels;
use Illuminate\Support\Facades\Log;
class DispatchScheduledBookingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public readonly string $bookingId,
) {}
public function handle(BookingStateMachine $stateMachine): void
{
$booking = Booking::find($this->bookingId);
if (!$booking) {
Log::warning("DispatchScheduledBookingJob: booking [{$this->bookingId}] not found.");
return;
}
if (!$booking->isInStatus(BookingStatus::Scheduled)) {
// The rider cancelled before we got here. Don't error, just stop.
Log::info("DispatchScheduledBookingJob: booking [{$this->bookingId}] no longer scheduled. Skipping.");
return;
}
try {
$stateMachine->toSearching($booking, [
'scheduled_at' => $booking->scheduled_at->toIso8601String(),
'is_scheduled' => true,
]);
} catch (\Throwable $e) {
Log::error("DispatchScheduledBookingJob: failed for booking [{$this->bookingId}]: {$e->getMessage()}");
throw $e; // let it retry — $tries = 3 above gives it two more chances
}
}
}Notice what's not in that constructor: no Booking $booking object, just a plain string $bookingId. That's deliberate, not an oversight. If you pass an Eloquent model into a job, Laravel serializes it — but by the time a worker actually picks the job up, the underlying row might have changed, or been deleted entirely. Passing the ID and re-fetching fresh inside handle() means you're always working with current data, not a stale snapshot from when the job was dispatched.
The Pattern Nobody Teaches: A Job That Reschedules Itself
Most queue tutorials stop at "dispatch a job, it runs once." But a lot of real background work isn't one-shot — it's a process that needs to keep checking something until a condition is met, without tying up a worker the whole time. Here's how that actually looks in production. This job watches a scheduled ride waiting for a driver match, and instead of a cron job polling every few seconds, it dispatches a delayed copy of itself:
<?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\SerializesModels;
use Illuminate\Support\Facades\Log;
class ScheduledRideMatchingCycleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1; // no automatic retry — we re-queue ourselves explicitly
public int $timeout = 60;
public function __construct(public readonly string $bookingId) {}
public function handle(BookingStateMachine $stateMachine, PushNotificationService $push): void
{
$booking = Booking::find($this->bookingId);
if (!$booking) {
return;
}
$status = $booking->status;
// Guard: stop cycling the moment the booking leaves a searchable state
$searchable = [BookingStatus::Searching, BookingStatus::DriverFound];
if (!in_array($status, $searchable, strict: true)) {
Log::info("ScheduledRideMatchingCycleJob: booking [{$this->bookingId}] is [{$status->value}] — stopping cycle.");
return;
}
// Auto-cancel if the scheduled departure time has already passed with no driver
if (now()->gte($booking->scheduled_at)) {
$stateMachine->toCancelled(
booking: $booking,
reason: "No driver found within the search window.",
cancelledBy: CancelledBy::System->value,
);
$push->sendScheduledRideReminder(
customerId: $booking->rider_id,
bookingId: $booking->id,
title: 'No Driver Available',
body: 'We could not find a driver for your scheduled ride. You will not be charged.',
);
return;
}
// Still waiting — reschedule this exact job to check again shortly
$this->reschedule($booking->matching_window_min ?? 4);
}
private function reschedule(int $windowMin): void
{
static::dispatch($this->bookingId)->delay(now()->addMinutes($windowMin));
}
}Read that reschedule() method again — static::dispatch($this->bookingId)->delay(...). The job dispatches a fresh copy of itself, delayed by a few minutes, and then exits. No worker sits there polling in a loop; each cycle is a completely separate, cheap dispatch that only exists for the seconds it takes to check one condition. This is the pattern to reach for whenever you catch yourself writing "keep checking until X happens" — a self-rescheduling job scales far better than a long-running worker or a tight cron interval, because it only consumes a worker slot for the instant it's actually doing something.
It's also why $tries = 1 is correct here, not a bug — Laravel's normal retry mechanism is for "this attempt failed unexpectedly, try again." This job never fails in the traditional sense; it succeeds every time and simply decides whether that success means "stop" or "queue the next check." Retrying it the normal way would fight with the explicit rescheduling logic already baked into handle().
Batching: Same Real Problem, Restructured
The scheduled-ride watchdog above handles one booking at a time by design — but a related job in the same system, expiring stale driver offers, deals with a whole set of records in one run:
class ExpireDriverOffersJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function handle(DriverOfferService $offerService): void
{
$expired = DriverOffer::expired()->with('booking')->get();
if ($expired->isEmpty()) {
return;
}
foreach ($expired as $offer) {
try {
$offerService->expireOffer($offer);
} catch (\Throwable $e) {
Log::error("ExpireDriverOffersJob: failed to expire offer [{$offer->id}]: {$e->getMessage()}");
}
}
}
}This runs on a schedule every ten seconds and just loops — one job, handling however many offers happen to be expired at that moment, catching each failure individually so one bad row never blocks the rest. For the volume this system actually sees, that's the right call: simple, and a single worker chews through it in well under a second.
But say that same loop started processing thousands of records per run instead of a handful, or each iteration involved a slow external API call instead of a local database update. That's exactly the point where you'd promote this from "one job with an internal loop" to a real batch — each unit of work becomes its own queued job, so it can be retried individually, run across multiple workers in parallel, and reported on as a group:
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
$jobs = DriverOffer::expired()->get()->map(
fn ($offer) => new ExpireSingleDriverOfferJob($offer->id)
);
Bus::batch($jobs)
->then(function (Batch $batch) {
Log::info("Expired all offers in batch {$batch->id} successfully.");
})
->catch(function (Batch $batch, Throwable $e) {
// First individual failure in the batch — still processes the rest.
Log::error("Offer expiry batch {$batch->id} had a failure: {$e->getMessage()}");
})
->dispatch();The difference that actually matters: in the loop version, one offer throwing an exception is caught inline and logged, but the whole run is still a single job — if the worker process itself dies mid-loop, everything after that point silently never runs. In the batch version, each offer is an independent job Laravel tracks separately; a dead worker only loses whatever that one job was doing, and $batch->failedJobs tells you exactly how many — and which — actually failed, not just that "something in the loop threw." Don't reach for a batch until you're actually paying for that gap; for a ten-second cron job handling a handful of rows, the loop is simpler and just as correct.
Chains: Formalizing What This Codebase Already Does Informally
Go back to DispatchScheduledBookingJob from the top of this post. Look at what happens right after it successfully transitions the booking to Searching — in the real version of that job, the very next line is:
// Kick off the matching cycle — runs every matching_window_min
// and handles offer sending, buffer_min auto-cancel, and re-matching.
ScheduledRideMatchingCycleJob::dispatch($this->bookingId);That's a chain, just not written as one. One job dispatches the next only after its own work succeeds — if the first job throws before reaching that line, the second job never gets queued. That's exactly the guarantee Bus::chain() gives you, formalized and with proper failure handling built in, instead of hand-rolled at the bottom of a method:
use Illuminate\Support\Facades\Bus;
use Throwable;
Bus::chain([
new DispatchScheduledBookingJob($bookingId),
new ScheduledRideMatchingCycleJob($bookingId),
])
->catch(function (Throwable $e) {
// Either job failing lands here — the chain stops, nothing after it runs.
Log::error("Scheduled booking pipeline failed: ".$e->getMessage());
})
->dispatch();The manual version above works fine for two steps where the second is a one-liner. Once a pipeline grows to three or four dependent steps, or you want one shared failure handler instead of a try/catch repeated at the end of every job, Bus::chain() is worth the small refactor — it's the same guarantee, made explicit instead of implicit in the last line of a method.
Failed Jobs: Two Real Jobs, Two Correct-But-Opposite Choices
Compare how two real jobs in this system handle their own failures — same codebase, deliberately different choices, both correct for what they're doing:
// SendOnboardingConfirmationJob — one clear thing to do, for one specific record.
public function handle(DynamicMailConfigService $mailConfig): void
{
$application = OnboardingApplication::find($this->applicationId);
if (!$application) {
Log::warning('Application not found', ['application_id' => $this->applicationId]);
return;
}
try {
Mail::to($application->email)->send(new MailTemplate($subject, $html));
} catch (\Throwable $e) {
Log::error('Onboarding confirmation email failed', ['error' => $e->getMessage()]);
throw $e; // re-throw — let the queue retry, and eventually land in failed_jobs
}
}// CashDepositReviewReminderJob — one run, many records; one bad record
// should never stop the rest from being notified.
foreach ($requests as $request) {
try {
$push->sendCashDepositPendingReminderToAdmin($adminId, ...);
} catch (\Throwable $e) {
Log::warning("push failed for admin [{$adminId}]: {$e->getMessage()}");
// deliberately swallowed — one admin's broken device token
// shouldn't stop every other admin from getting reminded
}
}The onboarding email re-throws because there's exactly one email to send, to one applicant, and if it fails you genuinely want Laravel's retry mechanism to try again — and if all retries are exhausted, you want it sitting in failed_jobs where someone will actually notice and manually resend it. The reminder job swallows the exception because it's inside a loop over potentially dozens of admins; letting one throw would abort the whole job and silently skip every admin after the one that failed. Neither choice is "the right way to handle queue failures" in the abstract — the right choice depends entirely on whether failure of one unit should be allowed to affect the others in the same run.
When something does exhaust its retries and land in failed_jobs, the recovery path is the same either way:
# See what actually failed
php artisan queue:failed
# Retry one specific failure by ID, after you've fixed whatever caused it
php artisan queue:retry 5
# Or retry everything
php artisan queue:retry allThe Rule Nobody Tells You Until You Break It: Never Just Delete a Job Class
Here's a real one, from the same codebase, and it's the kind of lesson you only learn by getting bitten once. A confirmation step in the booking flow got removed — bookings now go straight to driver search instead of waiting for a rider to confirm. The job that used to handle the old confirmation timeout was no longer needed. The instinct is to delete the class. Don't:
/**
* No-op — retained so any jobs serialized in the queue before this change
* can still be deserialized without a ClassNotFoundException.
*
* The confirmation step was removed: DispatchScheduledBookingJob now
* dispatches directly to Searching. This class will never act again.
*/
class AutoCancelUnconfirmedBookingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public readonly string $bookingId,
) {}
public function handle(): void
{
Log::info("AutoCancelUnconfirmedBookingJob: no-op — confirmation step removed.");
}
}Here's why this matters: a queued job isn't a live object sitting in memory — it's a serialized payload sitting in a database row or a Redis list, including the fully-qualified class name, waiting for a worker to pick it up. If you delete the class and deploy, any job of that type still sitting in the queue at deploy time throws a hard ClassNotFoundException the moment a worker tries to unserialize it — not a clean failure you can catch, a crash in the deserialization step itself, before your code even runs. The fix costs nothing: gut the class down to a no-op, leave a comment explaining why, and let it quietly absorb whatever's left in the queue until it naturally drains. Only delete the file once you're certain nothing of that type could possibly still be queued — in practice, that's a job for a future cleanup pass, not the same deploy that removed the feature.
Laravel 13: The Same Real Jobs, With Attributes
Everything above predates Laravel 13's attribute-based job configuration — public int $tries = 3; public int $backoff = 30; as plain class properties. Laravel 13 replaces that with declarative attributes sitting above the class. Here's the exact scheduled-booking job from earlier, updated:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
#[Tries(3)]
#[Backoff(delay: 30)]
class DispatchScheduledBookingJob implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $bookingId,
) {}
public function handle(BookingStateMachine $stateMachine): void
{
// identical body — only the retry configuration moved
}
}Nothing about handle() changed — only where the retry policy lives. That matters more than it looks: it means the retry/backoff/timeout contract for a job is now readable without opening the class body at all, which is genuinely useful once a codebase has fifty job classes and you're trying to remember which ones are safe to retry aggressively. Two attributes worth adding to jobs like the ones above, that didn't exist before Laravel 13:
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\MaxExceptions;
#[Tries(3)]
#[Backoff(delay: 30)]
#[MaxExceptions(2)] // fail fast if the state machine keeps throwing,
// even if $tries hasn't run out yet
#[DeleteWhenMissingModels] // if the booking is genuinely gone by the time
class DispatchScheduledBookingJob implements ShouldQueue // a worker picks
{ // this up, delete
// ... // instead of erroring
}#[DeleteWhenMissingModels] in particular would have made the manual if (!$booking) { Log::warning(...); return; } guard at the top of the original job unnecessary — Laravel handles the "the record disappeared before this ran" case for you, automatically, instead of every job author re-writing the same null check.
Your Turn
What's the trickiest queue bug you've ever shipped and then had to debug at 2am — a job that silently died, a batch that finished out of order, a chain that just... stopped halfway through? Tell me about it in the comments. If it's a good one, I'll break it down properly in a follow-up post.
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.