Queues, Jobs & Events
Async work, listeners, retries and failure handling.
Some work shouldn't happen while the user waits: sending emails, resizing images, calling slow third-party APIs, generating reports. Laravel's queue system moves that work into background jobs processed by separate worker processes. Events let one part of the app announce that something happened without knowing who cares.
In this lesson the Projects & Tasks app reacts to work being done. When a task is completed, Task::markComplete() fires a real TaskCompleted event, and a queued listener records it, without slowing down the HTTP response. We'll also design a notification job for when a task is assigned.
Why queue at all?
| Synchronous (in the request) | Queued (in a worker) |
|---|---|
| User waits for SMTP / Slack / S3 | Response returns immediately |
| A third-party outage = a 500 error for the user | Job retries later with backoff |
| PHP-FPM workers tied up for seconds | Web workers stay free for real requests |
| Work is lost if the request dies | Job stays in the queue until processed |
The moving parts
- Dispatching serialises the job class and its constructor arguments and pushes it to a queue connection.
- A worker (
php artisan queue:work) is a long-running PHP process that pops jobs and callshandle(). - If
handle()throws, the job is released back onto the queue to retry after a backoff delay. - When attempts run out, the job's
failed()method runs and it's written tofailed_jobsfor inspection and manual retry.
Configuring a connection
# .env
QUEUE_CONNECTION=database # simplest: uses a jobs table
# QUEUE_CONNECTION=redis # fast, supports Horizon
# QUEUE_CONNECTION=sqs # managed on AWS, scales to zero
# New apps already ship a jobs table migration (0001_01_01_000002_create_jobs_table.php).
# Only if yours is missing it:
php artisan make:queue-table
php artisan migrate
QUEUE_CONNECTION=sync runs jobs immediately inside the request. That's handy in local development and tests, but it isn't really a queue.
Writing a job
Illustrative — not in this app. The demo app's queued work is the LogTaskCompletion listener covered under Events and listeners. This job is a design for the next feature: notifying an assignee.
# Not part of the demo app: a design for the next feature
php artisan make:job SendTaskAssignedNotification
namespace App\Jobs;
use App\Models\Task;
use App\Notifications\TaskAssignedNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Throwable;
#[Tries(5)]
#[Backoff([10, 30, 90, 270])] // seconds before retries 1, 2, 3, then 270 for any further ones
#[Timeout(30)] // worker kills the job after 30s
#[DeleteWhenMissingModels] // silently drop it if the task was deleted meanwhile
class SendTaskAssignedNotification implements ShouldQueue
{
use Queueable;
public function __construct(public Task $task) {}
public function handle(): void
{
// The model is re-fetched fresh from the DB when the job runs
$this->task->assignee?->notify(new TaskAssignedNotification($this->task));
}
public function failed(?Throwable $e): void
{
report($e);
// e.g. flag the task so the UI can show "notification failed"
}
}
Laravel 13 job attributes. #[Tries], #[Backoff], #[Timeout], #[MaxExceptions], #[UniqueFor] and friends live in Illuminate\Queue\Attributes. The older property and method style (public int $tries = 5;, public function backoff(): array) still works and is what you'll see in most existing codebases. Use a method when the value must be computed at runtime.
The Queueable trait (with SerializesModels behaviour) stores only the model's class and ID in the payload, not the whole object. When the worker runs the job, it re-queries the database. Payloads stay small and the data stays fresh, but the row must still exist. That's what #[DeleteWhenMissingModels] handles: without it, the job fails with a ModelNotFoundException.
Dispatching
Illustrative — not in this app.
SendTaskAssignedNotification::dispatch($task);
// Options
SendTaskAssignedNotification::dispatch($task)
->onQueue('notifications') // named queue
->delay(now()->addMinutes(5)); // not before 5 minutes from now
// Only dispatch once the surrounding DB transaction commits
SendTaskAssignedNotification::dispatch($task)->afterCommit();
The classic race condition: you dispatch a job inside DB::transaction(), and a fast worker picks it up before the transaction commits. The worker can't find the new row, or sees stale data. Use ->afterCommit(), or set 'after_commit' => true on the connection in config/queue.php.
Simulate retries and backoff
Configure a flaky job and see how attempts, backoff and failed_jobs interact:
Events and listeners
Jobs answer the question do this later. Events answer a different one: this happened, and whoever cares can react. The code that completes a task shouldn't need to know about activity logs, emails or Slack.
This flow is real in this app:
php artisan make:event TaskCompleted
php artisan make:listener LogTaskCompletion --event=TaskCompleted
The event is a plain data object. It carries the task and nothing else:
app/Events/TaskCompleted.php
namespace App\Events;
use App\Models\Task;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TaskCompleted
{
use Dispatchable, SerializesModels;
public function __construct(public Task $task) {}
}
The listener implements ShouldQueue, so the dispatcher pushes it onto the queue instead of running it inline. Queued listeners take the same retry settings as jobs. This one uses the property style:
app/Listeners/LogTaskCompletion.php
namespace App\Listeners;
use App\Events\TaskCompleted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
class LogTaskCompletion implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
/**
* @var array<int, int>
*/
public array $backoff = [10, 60];
public function handle(TaskCompleted $event): void
{
$task = $event->task->loadMissing('project');
Log::info('Task completed', [
'task_id' => $task->id,
'title' => $task->title,
'project' => $task->project->slug,
]);
}
}
Laravel auto-discovers listeners by scanning app/Listeners for handle() methods type-hinted with an event, so there's no manual registration. The model fires the event itself, so every caller gets the behaviour for free:
app/Models/Task.php
public function markComplete(): void
{
if ($this->isComplete()) {
return; // idempotent: no duplicate events
}
$this->update(['completed_at' => now()]);
TaskCompleted::dispatch($this);
}
Where does the listener actually run here? Local development uses QUEUE_CONNECTION=sync (see docker-compose.yml), so LogTaskCompletion runs immediately, inside the request. That's handy for debugging. Switch the connection to database or redis and run php artisan queue:work, and the same code runs in the background with no changes. That's the point of ShouldQueue.
The guard clause matters. Calling markComplete() twice fires the event once, which the feature test TaskCompletionTest pins down with Event::fake() (see Testing & Performance).
Model events and observers
Eloquent fires its own events: creating, created, updating, updated, deleting and more. An observer groups the handlers. Illustrative — not in this app:
#[ObservedBy(TaskObserver::class)]
class Task extends Model { /* ... */ }
class TaskObserver
{
public function updated(Task $task): void
{
if ($task->wasChanged('user_id') && $task->user_id) {
SendTaskAssignedNotification::dispatch($task)->afterCommit();
}
}
}
Events or direct calls? Events decouple what happened from what to do about it. Adding a Slack notification means adding a listener, not editing the controller. The trade-off is indirection: you can't see every side effect by reading the controller. Run php artisan event:list to see the wiring. Observers are convenient, but they fire on every save, including seeders and imports, so keep them light.
Advanced job patterns
Illustrative — not in this app.
Uniqueness and overlap
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\Middleware\RateLimited;
class RebuildProjectReport implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Project $project) {}
// Only one pending job per project at a time
public function uniqueId(): string
{
return (string) $this->project->id;
}
public function middleware(): array
{
return [
new WithoutOverlapping($this->project->id), // never run two at once
new RateLimited('reports'), // see RateLimiter::for('reports')
];
}
}
Chains and batches
use Illuminate\Support\Facades\Bus;
// Run in order; stop if one fails
Bus::chain([
new ExportTasksToCsv($project),
new UploadExportToS3($project),
new EmailExportLink($project, $user),
])->dispatch();
// Run in parallel; track progress; callback when all are done
$batch = Bus::batch(
$project->tasks->map(fn ($task) => new GenerateTaskThumbnail($task))
)->then(fn ($batch) => $project->update(['thumbnails_ready' => true]))
->catch(fn ($batch, $e) => report($e))
->name("thumbnails:{$project->id}")
->dispatch();
// $batch->progress() → 0-100 for a progress bar
Running workers in production
A worker is a long-lived process, so something must keep it alive and restart it on deploy. On a VPS, that's usually Supervisor. Illustrative — not in this app:
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=notifications,default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
user=www-data
stopwaitsecs=3600
Where this site stands. Production is a self-hosted VPS running nginx and PHP-FPM, with QUEUE_CONNECTION=sync, so LogTaskCompletion runs inline during the request and no worker process is needed. Switching to real background processing takes two steps: set QUEUE_CONNECTION=database (the jobs table already exists), then keep php artisan queue:work running under supervisor or a systemd service like the one above. The local Docker image already runs nginx and PHP-FPM under supervisord (docker/supervisord.conf), so there a worker would be one more [program:] block.
Key operational commands:
php artisan queue:work --queue=high,default # priority order
php artisan queue:restart # gracefully restart workers after a deploy (they cache code!)
php artisan queue:failed # list failed jobs
php artisan queue:retry all # push them back on the queue
php artisan queue:prune-failed --hours=48
Workers boot the framework once and keep it in memory. That's why they're fast, and also why a deploy isn't live for them until you run queue:restart. Use --max-time or --max-jobs so workers recycle periodically, which guards against memory leaks.
Key takeaways
- Queue anything slow or unreliable. Jobs implement
ShouldQueue, are serialised to a backend and run inqueue:workprocesses. - Configure retries with
#[Tries],#[Backoff]and#[Timeout](or the equivalent properties). Handle permanent failure infailed()and inspectfailed_jobs. - Dispatch with
->afterCommit()when inside a transaction, or the worker may run before the data exists. - Events and listeners decouple side effects. Listeners can themselves be queued with
ShouldQueue. - Use
ShouldBeUnique,WithoutOverlapping, chains and batches for real workflows. Supervise workers and runqueue:restarton every deploy.