The Dispatch Flow – What Happens on dispatch()
When you call SendEmail::dispatch($user), Laravel runs this sequence synchronously before returning:
Job instantiation — new SendEmail($user) runs your constructor. SerializesModels registers the model for serialization.
Queue resolution — Laravel resolves the queue manager from the container, determines the connection (from $connection property or config default), and the queue name.
Payload building — the job is serialized via PHP's serialize(). SerializesModels replaces Eloquent model instances with a lightweight identifier (class + ID). The payload is wrapped in a JSON envelope with metadata.
Driver write — the payload is written to the queue backend. For database: one INSERT into the jobs table. For Redis: one RPUSH to a Redis list. For SQS: one SendMessage API call.
Return — dispatch() returns a PendingDispatch object. Your code continues immediately.
// What the JSON payload envelope looks like (database jobs table, payload column):
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "App\\Jobs\\SendWelcomeEmail",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": 3,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": [30, 60, 120],
"timeout": 60,
"retryUntil": null,
"data": {
"commandName": "App\\Jobs\\SendWelcomeEmail",
"command": "O:27:\"App\\Jobs\\SendWelcomeEmail\":2:{s:4:\"user\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":4:{s:5:\"class\";s:15:\"App\\Models\\User\";s:2:\"id\";i:42;s:9:\"relations\";a:0:{}s:10:\"connection\";s:5:\"mysql\";}}"
}
}
Job Serialization & the Payload Structure
The SerializesModels trait intercepts PHP's native __sleep() and __wakeup() magic methods. On sleep (serialization), it replaces model instances with a ModelIdentifier struct. On wakeup (deserialization), it fetches a fresh model from the database by ID.
// What SerializesModels does internally (simplified):
// On dispatch (serialize):
public function __sleep(): array
{
foreach ($this->properties as $property) {
if ($property->getValue($this) instanceof Model) {
// Replace the model with just its ID + class
$property->setValue($this, new ModelIdentifier(
get_class($model), $model->getKey(), [], $model->getConnectionName()
));
}
}
return array_keys((array) $this);
}
// On execution (deserialize):
public function __wakeup(): void
{
foreach ($this->properties as $property) {
if ($property->getValue($this) instanceof ModelIdentifier) {
$identifier = $property->getValue($this);
// Re-fetch fresh from DB — picks up any changes since dispatch
$model = $identifier->class::on($identifier->connection)->find($identifier->id);
$property->setValue($this, $model);
}
}
}
⚠️ This is why you should never load relationships in the constructor and pass the model. The relationships are stripped during serialization. Load them inside handle() instead.
The Worker Loop – How Jobs Are Picked Up
The queue:work command starts a long-running PHP process that runs this loop continuously:
// Simplified worker loop (Illuminate\Queue\Worker::daemon())
while (true) {
// 1. Check for restart signal (from queue:restart)
if ($this->shouldRestart($lastRestart)) {
$this->stop();
}
// 2. Check memory limit
if ($this->memoryExceeded($memory)) {
$this->stop();
}
// 3. Try to get next job
$job = $this->getNextJob($connection, $queue);
if ($job) {
// 4. Process the job
$this->runJob($job, $connectionName, $options);
} else {
// 5. No jobs — sleep and try again
$this->sleep($options->sleep); // default 3 seconds
}
}
// For Redis: getNextJob uses BRPOPLPUSH (blocking pop)
// Worker blocks for up to $timeout seconds waiting for new jobs
// When a job arrives, Redis unblocks the worker immediately
// No polling delay — Redis workers pick up jobs instantly
Job Execution – From Payload to handle()
Once a worker gets a job, this sequence runs:
Mark reserved — sets reserved_at in the jobs table (or Redis) so other workers skip it. Atomic operation.
Deserialize — unserialize() reconstructs the job class. __wakeup() re-fetches Eloquent models from the database.
Fire JobProcessing event — lifecycle hook for listeners.
Run middleware — calls each middleware's handle() in order, wrapping the job like an onion.
Resolve dependencies — the container injects type-hinted dependencies into the handle() method.
Call handle() — your business logic runs.
Delete from queue — on success, the job row is deleted from the jobs table.
Fire JobProcessed event — lifecycle hook for listeners.
On exception: the job increments its attempts, calculates the next retry time using the backoff, updates available_at, clears reserved_at, and fires a JobFailed event. If max attempts are reached, it's moved to failed_jobs.
Lifecycle Hooks & Queue Events
Laravel fires events at every stage of the job lifecycle. Listen to them in a service provider for cross-cutting concerns like metrics, logging, or alerting:
// app/Providers/AppServiceProvider.php
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobExceptionOccurred;
public function boot(): void
{
// Fires before a job runs
Queue::before(function (JobProcessing $event) {
\Log::debug('Processing: ' . $event->job->resolveName());
});
// Fires after a job succeeds
Queue::after(function (JobProcessed $event) {
Metrics::increment('queue.jobs.processed', [
'job' => $event->job->resolveName(),
]);
});
// Fires when an exception occurs (but job may still retry)
Queue::exceptionOccurred(function (JobExceptionOccurred $event) {
\Log::warning('Job exception: ' . $event->exception->getMessage(), [
'job' => $event->job->resolveName(),
'attempt' => $event->job->attempts(),
]);
});
// Fires when a job permanently fails (no more retries)
Queue::failing(function (JobFailed $event) {
\Notification::route('slack', config('services.slack.alerts'))
->notify(new JobFailedAlert($event->job->resolveName(), $event->exception));
});
// Fires when the worker is about to sleep (no jobs available)
Queue::looping(function () {
// Good place for per-loop maintenance tasks
// But be careful — runs very frequently
DB::reconnect(); // refresh stale DB connections
});
}
Atomic Locking – How Workers Avoid Duplicates
The most important implementation detail: how do multiple workers pull from the same queue without processing the same job twice?
Database Driver — SELECT FOR UPDATE
-- Simplified version of the database driver's job-fetching query
-- The FOR UPDATE SKIP LOCKED is the key — atomic and non-blocking
SELECT * FROM jobs
WHERE queue = 'default'
AND (reserved_at IS NULL OR reserved_at <= UNIX_TIMESTAMP() - 90)
AND available_at <= UNIX_TIMESTAMP()
ORDER BY id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED; -- other workers skip this row immediately
-- Then immediately update to mark as reserved:
UPDATE jobs SET reserved_at = UNIX_TIMESTAMP(), attempts = attempts + 1
WHERE id = ?;
Redis Driver — BRPOPLPUSH (Atomic Pop)
-- Redis uses an atomic BRPOPLPUSH command
-- Pops from the "queues:default" list AND pushes to "queues:default:reserved" atomically
-- No two workers can pop the same item — Redis guarantees atomicity
BRPOPLPUSH queues:default queues:default:reserved 0
-- On completion, remove from reserved list:
LREM queues:default:reserved 0 "{job_payload}"
This is why Redis queues scale better: the locking is handled at the data structure level with O(1) atomic operations, not with row-level database locks that cause contention.
Conclusion
Understanding the internals of Laravel Queue makes you a significantly better developer because you can:
Diagnose serialization bugs by understanding what SerializesModels does at each step
Understand why Redis workers are faster than database workers (BRPOPLPUSH vs SELECT FOR UPDATE)
Use lifecycle events (Queue::before, Queue::failing) for cross-cutting monitoring without touching every job
Know exactly why jobs appear "stuck" (reserved_at set but never cleared) and how retry_after unsticks them
Understand why queue:restart works across all servers — it writes a timestamp to shared cache that every worker checks in its loop