Copied!
Programming
Laravel
PHP

Under the Hood of Laravel Queue – Dispatch, Serialization, Lifecycle & Events

Under the Hood of Laravel Queue – Dispatch, Serialization, Lifecycle & Events
Shahroz Javed
Apr 06, 2026 . 25 views

The Dispatch Flow – What Happens on dispatch()

When you call SendEmail::dispatch($user), Laravel runs this sequence synchronously before returning:

  1. Job instantiationnew SendEmail($user) runs your constructor. SerializesModels registers the model for serialization.

  2. Queue resolution — Laravel resolves the queue manager from the container, determines the connection (from $connection property or config default), and the queue name.

  3. 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.

  4. 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.

  5. Returndispatch() 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:

  1. Mark reserved — sets reserved_at in the jobs table (or Redis) so other workers skip it. Atomic operation.

  2. Deserializeunserialize() reconstructs the job class. __wakeup() re-fetches Eloquent models from the database.

  3. Fire JobProcessing event — lifecycle hook for listeners.

  4. Run middleware — calls each middleware's handle() in order, wrapping the job like an onion.

  5. Resolve dependencies — the container injects type-hinted dependencies into the handle() method.

  6. Call handle() — your business logic runs.

  7. Delete from queue — on success, the job row is deleted from the jobs table.

  8. 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

📑 On This Page