The 4-Second Signup: What dispatch() Actually Does
A user signs up. Your controller writes the row, then calls the SMTP provider to send the welcome email. The provider takes 3.5 seconds to answer; your database write took 8 ms. The user stares at a spinner for four seconds so that a message they may never open can leave the building.
Dispatching a job changes where that time is spent. dispatch() does not run your work — it serializes the job's class name and its constructor data into a payload and appends that payload to a queue (a database table, a Redis list, an SQS queue). Your HTTP request returns immediately; a separate long-lived worker process pops the payload later and executes handle().
// app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(public User $user) {}
public function handle(Mailer $mailer): void
{
$mailer->to($this->user->email)
->send(new WelcomeMail($this->user->name));
}
}
// app/Http/Controllers/AuthController.php
public function store(Request $request)
{
$data = $request->validate([/* the rules from [Validating Requests](/topic/php-laravel-validation) */]);
$user = User::create($data);
SendWelcomeEmail::dispatch($user); // payload queued; nothing is emailed yet
return redirect()->route('welcome'); // returns in ~50 ms
}
The ShouldQueue interface is the entire switch. A job class without it runs inline inside your request; with it, the payload goes to whatever broker QUEUE_CONNECTION names:
| Driver | Payload lives in | Extra infrastructure | Typical fit |
|---|---|---|---|
sync |
nowhere — runs in-process | none | local dev, trivial work |
database |
jobs table |
the DB you already run | low-volume production |
redis |
a Redis list | one Redis server | the standard Laravel setup |
sqs |
AWS SQS | AWS-managed service | managed retries + dead-letter queues |
QUEUE_CONNECTION=sync in development is convenient and misleading: the job runs inside the request, so every blocking API call and every 30-second export silently returns to the user. Run at least one environment against a real driver, or you will meet queue-only failures — serialization errors, timeouts, duplicate delivery — for the first time in production.
What gets serialized matters. The Queueable trait pulls in SerializesModels, which stores the model's class and primary key instead of the whole object; when the worker runs the job it re-queries the row. Two reasons: a PDO connection (or a closure) cannot be serialized at all, and re-querying means the worker sees the row as it exists at execution time, not at dispatch time. The price is that a row deleted in between makes the job fail with a missing-model error — you will meet that path in the final chapter.
Queues buy reliability as well as latency. If PHP-FPM dies right after dispatch(), the payload is already durable in the broker and the worker still sends the email; an SMTP call made inside the request dies with the request.
You also get scheduling and fan-out for free once the work is a payload:
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10)); // remind non-activers
GenerateInvoice::dispatch($order)->onQueue('reports'); // separate consumer
Bus::chain([new FetchCsv, new ValidateCsv, new ImportCsv])->dispatch();
Chaining matters because each step gets its own retry budget: if ValidateCsv fails at step two, FetchCsv does not re-run — only the failed step is released. That is the correct semantics for a multi-stage pipeline, and it is much harder to bolt onto a synchronous loop inside a controller.
Interviewers ask "when does this belong on a queue?" Answer with two budgets: if the work takes longer than ~100 ms, talks to a third party, or must survive a crash, it belongs on a queue — otherwise the extra moving part is not paying for itself. When you have many such jobs, Caching with Redis and Files shows you the same broker family doing double duty for reads.
Where the Payload Lives: Database, Redis, and SQS Delivery Semantics
Where a job sits between dispatch() and handle() decides its ordering, its latency, and how many times it can run. Every driver stores the same logical payload, but the mechanics differ enough to change your design.
A database-driver payload looks like this inside the payload column of the jobs table:
{
"uuid": "6f1c9e0a-2b77-4a6e-9c1e-0f4a3d2b91aa",
"displayName": "App\\Jobs\\SendWelcomeEmail",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"data": {
"commandName": "App\\Jobs\\SendWelcomeEmail",
"command": "O:28:\"App\\Jobs\\SendWelcomeEmail\":1:{s:4:\"user\";i:42;}"
},
"maxTries": 3,
"timeout": 60
}
The command blob is the PHP-serialized object — class plus constructor properties, model reduced to an id. The uuid is the job's identity: every attempt, every log line, and every failed_jobs row references it.
php artisan queue:table # creates jobs + failed_jobs migrations
php artisan migrate
php artisan queue:work database --queue=emails,default --sleep=3 --tries=3
php artisan queue:work --once # drain exactly one job, then exit (debugging)
How each driver hands work to a worker:
| Driver | Claim mechanism | Ordering | Failure window |
|---|---|---|---|
database |
worker polls and claims a row | FIFO within a queue, broken by retries | between claim and delete |
redis |
blocking pop (BRPOP) from a list |
FIFO per list, zero polling delay | between pop and ack |
sqs |
long polling; message becomes invisible while processed | best effort | visibility timeout expiry |
All three give you at-least-once delivery, and that is the sentence interviewers want to hear. A worker can crash after the side effect and before the acknowledgement — the message becomes visible again and a second worker runs the same job. Your handler must therefore be idempotent: the natural fix is a unique constraint or an upsert keyed on business data, so the duplicate run converges instead of double-charging.
public function handle(): void
{
// second delivery hits the unique index instead of minting a duplicate
Invoice::upsert(
['order_id' => $this->orderId, 'number' => $this->invoiceNo],
['order_id'],
['number']
);
}
The other ordering trap is the transaction race: User::create($data); SendWelcomeEmail::dispatch($user); inside a transaction that rolls back leaves a payload referencing a row that never existed — or, worse, the worker fires before the commit and the model query returns nothing. Opt in once and forget it:
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public bool $afterCommit = true; // or QUEUE_AFTER_COMMIT=true in .env
}
The broker then holds the payload until the transaction commits. This one flag removes a whole class of "job ran too early" bugs that only appear under load.
Queue priorities come from lists, not from the payload: dispatch with SendWelcomeEmail::dispatch($user)->onQueue('emails') and run queue:work --queue=emails,default — the worker drains emails completely before touching default. Note the consequence: a starving default queue is invisible until you watch queue depth, which is exactly what Running and Monitoring Workers is about.
Driver-specific details worth knowing before you choose one:
- Database — claims rows with a short transaction per job, so heavy volume adds row-lock churn to the same instance serving your reads. It also needs a pruning rule:
jobsandfailed_jobsgrow forever withoutphp artisan queue:prune --hours=48. - Redis —
LPUSHon dispatch, blocking pop on the worker side, so a queued job starts in milliseconds instead of after a poll interval. Separate it from cache traffic with its own database index (covered in Caching with Redis and Files). - SQS — the visibility timeout is the analogue of a lock: a message disappears from the queue while a worker holds it and reappears if the worker never deletes it. Tune
--timeout/AfterVisibilityTimeoutabove your longest job or messages will duplicate mid-flight.
Testing the whole loop locally takes one terminal: php artisan queue:work --once pops exactly one payload and exits, which lets you reproduce a serialization failure or a missing-model error without a daemon in the way.
Retries, failed_jobs, and Where a Dead Job Goes
Every job eventually throws. What happens next is a defined pipeline, and knowing it is the difference between "the queue backed up overnight" and "we retried it at 02:14 and moved on."
The pipeline runs like this:
handle()throws. The worker records an attempt (attemptsis stored with the job).- If
attempts < maxTries, the job is released back onto the queue with a delay —$backoffif you declared one, otherwise available again on the next poll. - Once attempts reach the limit, the broker deletes the message and the framework writes a row into
failed_jobs: payload, exception class and message,failed_at. - Nothing runs again until a human (or a cron rule) pushes it back with
queue:retry.
class ResizeImage implements ShouldQueue
{
use Queueable;
public int $tries = 3; // total attempts
public int $timeout = 120; // kill if handle() runs longer (needs pcntl)
public int|array $backoff = [10, 60]; // 10s after attempt 1, 60s after attempt 2
public function backoff(): void {} // or compute it dynamically
public function failed(?\Throwable $e): void
{
// runs exactly once, when the job lands in failed_jobs
Metrics::increment('image.resize.failed');
}
}
Backoff is what makes retries safe. A third-party API returning 503 needs a minute, not a thundering herd of immediate retries; linear or exponential delay lets the dependency recover. $backoff as an array is indexed by attempt number, so [10, 60, 300] gives you escalating patience.
The failure table is your dead-letter queue. The commands you will actually type:
php artisan queue:failed # list id, connection, queue, exception, failed_at
php artisan queue:retry 8f3c1a # re-dispatch one job by id
php artisan queue:retry all # re-dispatch everything
php artisan queue:retry "App\Jobs\ResizeImage" # by exception class... use ids for precision
php artisan queue:forget 8f3c1a # drop one row permanently
php artisan queue:flush # drop every failed job — destructive
php artisan queue:prune --hours=48 # trim old rows so the table stays small
queue:failed is the first command you run during an incident: it answers "what broke, when, and how many times." Retrying from there is safe because the handler is idempotent — a job that was half-done before the crash may run again.
Three traps catch almost everyone:
- Serialization of the payload fails silently at dispatch time if you captured a closure, a PDO handle, or an Eloquent collection that changed after dispatch. Keep job constructor arguments to scalars, model ids, and arrays.
- The model is gone when the job runs.
SerializesModelsre-queries by primary key; a row deleted between dispatch and execution raisesModelNotFoundExceptionand the job fails on its first attempt. Decide deliberately: fail fast, or checkModel::find()and return. syncswallows the pipeline. WithQUEUE_CONNECTION=syncthe exception propagates straight into your HTTP request — there is no retry, no backoff, no failed_jobs row. That is fine for tests and wrong for anything a user is waiting on.
An unwatched failed_jobs table is the queue equivalent of an unread error log, so wire it into the same alerting you use for exceptions: a scheduled count (Schedule::command('queue:failed')->hourly() printing the size, or a query against the table from your monitoring cron) whose threshold fires when rows grow, not merely when they exist. One row is a bug; forty rows in ten minutes is an incident.
Interviewers like this question: "A job failed three times — walk me through what the system did." Answer in the four steps above, name the table, and finish with how you would have noticed — which is the handoff to Running and Monitoring Workers. The scheduler in Scheduling Recurring Tasks is where nightly queue:prune and failed-job alerts usually live.
Quiz
1. With QUEUE_CONNECTION=database, you call SendWelcomeEmail::dispatch($user) in a controller. When does handle() execute?
2. A job with $tries = 3 and $backoff = [10, 60] throws on every attempt. What does the system do?
3. User::create() and SendWelcomeEmail::dispatch($user) both run inside a DB transaction. Why can the job still fail even though the code is correct?
4. Which sequence correctly recovers jobs sitting in failed_jobs?
Flashcards
Question
What does dispatch() do before any of your code runs?
Click to reveal answer
Answer
It serializes the job class name plus constructor data into a payload and appends it to the configured broker (jobs table, Redis list, or SQS message). The ShouldQueue interface is the switch: without it the job runs inline in the request; with it a separate worker executes handle() later.
Question
The delivery guarantee of the database, redis, and sqs drivers — and the coding discipline it forces
Click to reveal answer
Answer
At-least-once: a worker that crashes after the side effect but before the ack makes the message visible again, so the job can run twice. Handlers must be idempotent — a unique constraint or upsert on business data makes the duplicate converge instead of double-charge.
Question
What problem does $afterCommit = true solve?
Click to reveal answer
Answer
It defers the payload until the surrounding database transaction commits. Without it the worker (a separate connection) can run before commit and fail to re-query the model, or the transaction can roll back and leave a payload pointing at a row that never existed.
Question
Walk the retry pipeline from exception to recovery
Click to reveal answer
Answer
handle() throws → attempt recorded → job released with backoff while attempts < $tries → otherwise the broker drops it and failed_jobs gets a row (payload, exception, failed_at). Recovery: queue:failed lists, queue:retry <id|all> re-dispatches, queue:forget/queue:flush discard, queue:prune --hours trims.
Question
Why is QUEUE_CONNECTION=sync dangerous as a permanent environment setting?
Click to reveal answer
Answer
Jobs run in-process: no retry, no backoff, no failed_jobs row, and the exception bubbles into the HTTP response the user sees. Queue-only failures (serialization, timeouts, duplicate delivery) stay hidden until production — test at least one environment against a real driver.
Revision Notes
Key Takeaways
- 1. dispatch() appends a serialized payload to a broker; a separate worker runs handle() — only the sync driver executes inside the request, hiding every queue-only failure
- 2. SerializesModels stores class + primary key so the worker re-queries fresh data, which is why a deleted row fails the job and why $afterCommit exists
- 3. Database, Redis, and SQS all guarantee at-least-once delivery: a crash between side effect and acknowledgement replays the job, so idempotency is mandatory, not optional
- 4. The failure pipeline is finite by design: attempts, then backoff slots, then a failed_jobs row that queue:failed/queue:retry/queue:forget operate on
- 5. Priorities are separate lists (--queue=high,default), not payload metadata — a saturated high queue silently starves the default one
Interview Tips
- • "When would you move work to a queue?" — give two budgets: latency (anything over ~100 ms or touching a third party) and failure (work that must survive a crash); then name the cost you accepted — serialization, eventual consistency, and idempotency
- • "A job ran twice and charged the customer twice — why?" — at-least-once delivery plus a non-idempotent handler; show the fix (unique key/upsert) and the ack-crash window that caused it
- • "Walk me through what happens when a job fails three times" — attempts, release with backoff, failed_jobs row, then queue:failed → queue:retry; mention failed() for cleanup and that queue:restart only bounces workers
- • "How do you handle a job that must not run before the data exists?" — $afterCommit = true (or the queue.after_commit config), explaining the separate-connection visibility problem inside transactions
Cheat Sheet
Dispatching Queued Jobs
The pipeline: dispatch() serializes payload → broker → worker runs handle() → exception ⇒ release with backoff until $tries ⇒ then failed_jobs.
class SendWelcomeEmail implements ShouldQueue
{
use Queueable; // pulls in SerializesModels
public int $tries = 3;
public int $timeout = 120; // needs pcntl in the worker
public int|array $backoff = [10, 60];
public bool $afterCommit = true; // dispatch only after commit
public function __construct(public User $user) {}
public function handle(Mailer $mailer): void { /* ... */ }
public function failed(?\Throwable $e): void { /* cleanup / alert */ }
}
SendWelcomeEmail::dispatch($user)->onQueue('emails');
Drivers: sync in-process (no retries) · database polls jobs table · redis blocking pop · sqs long-poll + visibility timeout. All real drivers are at-least-once → make handlers idempotent.
php artisan queue:table && php artisan migrate
php artisan queue:work redis --queue=emails,default --sleep=3 --tries=3 --max-time=3600
php artisan queue:work --once # debug one job
php artisan queue:failed | queue:retry <id|all> | queue:forget <id>
php artisan queue:flush # destructive: drops all failed rows
php artisan queue:prune --hours=48