Laravel Queue System: Background Jobs
A user signs up, and the registration request hangs for four seconds because the controller is talking to an SMTP server. Or someone uploads a gallery, the image resizing runs inline, and nginx gives up with a 504 before PHP is done. The work itself is fine. It just doesn't belong inside the HTTP request.
Laravel's queues let you push that work onto a queue and have separate worker processes pick it up, so the request returns right away. This post covers how the pieces fit together, picking a driver, writing and dispatching jobs, chains and batches, rate limiting, failure handling, Horizon, and the deployment details that tend to bite people in production.
- Understanding queues, jobs, and workers in Laravel
- Configuring queue drivers: Redis, Database, SQS, and more
- Creating and dispatching jobs with best practices
- Job batching and chaining for complex workflows
- Rate limiting and throttling job execution
- Handling failed jobs and implementing retry strategies
- Monitoring queues with Laravel Horizon
- Production deployment and scaling strategies
Understanding the Queue Architecture
There are three moving parts: jobs (the work to be done), queues (where jobs wait), and workers (long-running processes that execute them). Your app only ever talks to the queue. It never waits for a worker.
Jobs are dispatched to a queue storage (Redis, database, etc.), then processed by worker processes running in the background.
Jobs
Self-contained classes that define the work to be done. Each job has a handle() method containing the actual logic.
Queues
Named channels where jobs wait to be processed. You can have multiple queues with different priorities.
Workers
Long-running PHP processes that pull jobs from queues and execute them. Run via php artisan queue:work.
Drivers
Backend storage for queues: Redis (recommended), database, Amazon SQS, Beanstalkd, or sync for local dev.
Configuring Queue Drivers
Laravel ships with several drivers. Which one you pick mostly comes down to what infrastructure you already run and how much traffic the queue will see.
| Driver | Best For | Pros | Cons |
|---|---|---|---|
| Redis | Production apps | Fast, reliable, supports delays/priorities | Requires Redis server |
| Database | Simple apps, getting started | No extra services needed | Slower, adds DB load |
| Amazon SQS | AWS-hosted apps, high scale | Managed, highly scalable | AWS lock-in, costs |
| Sync | Local development/testing | Immediate execution | Blocks request, no async |
Setting Up Redis Queue
For most production apps I'd go with Redis. It's fast, and it's the only driver Horizon supports. The setup is a few lines in .env:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Optional: Use a dedicated Redis database for queues
REDIS_QUEUE_DB=1
<?php
return [
'default' => env('QUEUE_CONNECTION', 'sync'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // Seconds before job is retried if not acknowledged
'block_for' => null, // Seconds to block while waiting for jobs
'after_commit' => false, // Dispatch after DB transaction commits
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
],
// Define which jobs go to which queues
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
Set after_commit to true if your jobs depend on database records created in the same request. Otherwise a fast worker can pick up the job before the transaction commits, and you get a ModelNotFoundException (No query results for model [App\Models\User]) for a row that definitely exists a second later.
Keep the job's $timeout (or the worker's --timeout) a few seconds shorter than the connection's retry_after. If a job runs longer than retry_after, the queue assumes it's lost and hands it to another worker while the first one is still running it. That's how people end up sending the same email twice.
Creating and Dispatching Jobs
A job is a plain class with a handle() method. The example below adds the properties and hooks you'll reach for most often: retries, backoff, timeouts, a failure handler, middleware and tags.
Generating a Job Class
# Create a basic job
php artisan make:job SendWelcomeEmail
# Create a job with the ShouldQueue interface
php artisan make:job ProcessPodcastUpload --queued
Job Class Structure
<?php
namespace App\Jobs;
use App\Models\User;
use App\Mail\WelcomeEmail;
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\Mail;
use Illuminate\Support\Facades\Log;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 30;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public int $maxExceptions = 2;
/**
* The number of seconds the job can run before timing out.
*/
public int $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public User $user,
public string $referralCode = ''
) {}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info("Sending welcome email to {$this->user->email}");
Mail::to($this->user->email)
->send(new WelcomeEmail($this->user, $this->referralCode));
// Update user record
$this->user->update(['welcome_email_sent_at' => now()]);
Log::info("Welcome email sent successfully to {$this->user->email}");
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error("Failed to send welcome email to {$this->user->email}", [
'user_id' => $this->user->id,
'error' => $exception->getMessage(),
]);
// Notify admin of the failure
// Notification::route('slack', config('services.slack.webhook'))
// ->notify(new JobFailedNotification($this, $exception));
}
/**
* Determine the middleware the job should pass through.
*/
public function middleware(): array
{
return [
new \Illuminate\Queue\Middleware\WithoutOverlapping($this->user->id),
];
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags(): array
{
return ['email', 'welcome', 'user:' . $this->user->id];
}
}
Dispatching Jobs
There are a few ways to dispatch, and they mostly differ in where and when the job ends up:
use App\Jobs\SendWelcomeEmail;
// Method 1: Static dispatch
SendWelcomeEmail::dispatch($user);
// Method 2: Using dispatch() helper
dispatch(new SendWelcomeEmail($user));
// Method 3: Using the Bus facade
use Illuminate\Support\Facades\Bus;
Bus::dispatch(new SendWelcomeEmail($user));
// Dispatch with additional constructor arguments
SendWelcomeEmail::dispatch($user, 'REFERRAL2024');
use Carbon\Carbon;
// Delay by seconds
SendWelcomeEmail::dispatch($user)->delay(60);
// Delay by minutes using Carbon
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10));
// Schedule for a specific time
SendWelcomeEmail::dispatch($user)->delay(
Carbon::tomorrow()->setHour(9)
);
// Delay with backoff strategy (useful for rate-limited APIs)
SendWelcomeEmail::dispatch($user)
->delay(now()->addSeconds(30))
->afterCommit(); // Only dispatch after DB transaction commits
// Dispatch to a specific queue
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Dispatch to a specific connection and queue
SendWelcomeEmail::dispatch($user)
->onConnection('redis')
->onQueue('high-priority');
// Set queue priority in the job class itself
class SendWelcomeEmail implements ShouldQueue
{
public $queue = 'emails'; // Default queue for this job
// Or dynamically set it
public function __construct(public User $user)
{
$this->onQueue($user->isPremium() ? 'priority-emails' : 'emails');
}
}
// Dispatch only if condition is true
SendWelcomeEmail::dispatchIf($user->wants_emails, $user);
// Dispatch unless condition is true
SendWelcomeEmail::dispatchUnless($user->email_verified, $user);
// Dispatch after response is sent to browser
SendWelcomeEmail::dispatchAfterResponse($user);
// Dispatch synchronously (bypass queue)
SendWelcomeEmail::dispatchSync($user);
// Dispatch and forget (no tracking)
dispatch(new SendWelcomeEmail($user))->afterResponse();
Job Chaining and Batching
Once one job turns into several, you need a way to say "run these in order" or "run these together and tell me when they're all done". That's what chains and batches are for.
Chaining executes jobs in sequence; batching executes jobs in parallel with callbacks when all complete.
Job Chaining
Use a chain when each step depends on the previous one. If any job in the chain fails, the rest don't run:
<?php
use App\Jobs\ProcessPodcast;
use App\Jobs\GenerateTranscript;
use App\Jobs\NotifySubscribers;
use Illuminate\Support\Facades\Bus;
// Basic chain
Bus::chain([
new ProcessPodcast($podcast),
new GenerateTranscript($podcast),
new NotifySubscribers($podcast),
])->dispatch();
// Chain with error handling
Bus::chain([
new ProcessPodcast($podcast),
new GenerateTranscript($podcast),
new NotifySubscribers($podcast),
])
->onQueue('podcasts')
->onConnection('redis')
->catch(function (Throwable $e) use ($podcast) {
// Handle chain failure
Log::error("Podcast chain failed: {$e->getMessage()}", [
'podcast_id' => $podcast->id
]);
$podcast->update(['status' => 'failed']);
})
->dispatch();
// Dynamic chain based on conditions
$jobs = [new ProcessPodcast($podcast)];
if ($podcast->wants_transcript) {
$jobs[] = new GenerateTranscript($podcast);
}
if ($podcast->notify_subscribers) {
$jobs[] = new NotifySubscribers($podcast);
}
Bus::chain($jobs)->dispatch();
Job Batching
Batches fit the "process 200 images, then mark the gallery done" case. Jobs run in parallel across workers, and you get callbacks when they finish. Each job class in a batch needs the Illuminate\Bus\Batchable trait, or the dispatch will fail.
<?php
use App\Jobs\ProcessImage;
use App\Models\Gallery;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
class GalleryController extends Controller
{
public function processGallery(Gallery $gallery)
{
$jobs = $gallery->images->map(function ($image) {
return new ProcessImage($image);
})->toArray();
$batch = Bus::batch($jobs)
->then(function (Batch $batch) use ($gallery) {
// All jobs completed successfully
$gallery->update([
'status' => 'processed',
'processed_at' => now(),
]);
// Notify user
$gallery->user->notify(new GalleryProcessed($gallery));
})
->catch(function (Batch $batch, Throwable $e) use ($gallery) {
// First batch job failure detected
Log::error("Gallery processing failed", [
'gallery_id' => $gallery->id,
'error' => $e->getMessage(),
'failed_jobs' => $batch->failedJobs,
]);
})
->finally(function (Batch $batch) use ($gallery) {
// Batch finished (success or failure)
$gallery->update([
'batch_id' => null, // Clear batch reference
'jobs_processed' => $batch->totalJobs - $batch->pendingJobs,
'jobs_failed' => $batch->failedJobs,
]);
})
->name("Process Gallery #{$gallery->id}")
->onQueue('image-processing')
->allowFailures() // Continue processing even if some jobs fail
->dispatch();
// Store batch ID for progress tracking
$gallery->update(['batch_id' => $batch->id]);
return response()->json([
'message' => 'Gallery processing started',
'batch_id' => $batch->id,
]);
}
public function checkProgress(Gallery $gallery)
{
$batch = Bus::findBatch($gallery->batch_id);
if (!$batch) {
return response()->json(['error' => 'Batch not found'], 404);
}
return response()->json([
'id' => $batch->id,
'name' => $batch->name,
'total_jobs' => $batch->totalJobs,
'pending_jobs' => $batch->pendingJobs,
'failed_jobs' => $batch->failedJobs,
'progress' => $batch->progress(),
'finished' => $batch->finished(),
'cancelled' => $batch->cancelled(),
]);
}
}
Job batching requires a database table to track batch status. Run php artisan queue:batches-table and php artisan migrate to create it.
Rate Limiting and Throttling
Twenty workers hitting a third-party API at full speed will get you rate-limited or banned fairly quickly. Job middleware lets you cap how fast jobs run without changing the job logic 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\Middleware\RateLimited;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
class SyncToExternalAPI implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Model $record
) {}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
// Global rate limit: max 60 jobs per minute across all workers
new RateLimited('external-api'),
// Throttle exceptions: if job fails, wait before retrying
(new ThrottlesExceptions(3, 5)) // 3 attempts, 5 min cooldown
->backoff(5), // Wait 5 minutes after throttling
// Prevent overlapping: only one job per record at a time
(new WithoutOverlapping($this->record->id))
->releaseAfter(60) // Release lock after 60 seconds
->expireAfter(180), // Lock expires after 3 minutes
];
}
public function handle(): void
{
// Your API sync logic here
$this->syncToApi();
}
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime
{
return now()->addHours(24); // Keep retrying for 24 hours
}
}
// Define the rate limiter in AppServiceProvider
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('external-api', function (object $job) {
return Limit::perMinute(60); // 60 jobs per minute
});
// Or with more sophisticated limits
RateLimiter::for('stripe-api', function (object $job) {
return [
Limit::perSecond(10), // 10 per second
Limit::perMinute(100), // 100 per minute
Limit::perHour(1000), // 1000 per hour
];
});
// User-specific rate limiting
RateLimiter::for('user-exports', function (object $job) {
return Limit::perHour(5)->by($job->user->id);
});
}
}
Rate Limiting Strategies
- RateLimited: Global rate limiting based on defined limiters
- ThrottlesExceptions: Exponential backoff on repeated failures
- WithoutOverlapping: Prevents duplicate processing of the same entity
- Skip: Skip job execution under certain conditions
Handling Failed Jobs
Jobs fail. APIs time out, databases deadlock, someone deletes the record the job was about to process. The question is whether you find out from a log or from an angry user, so it's worth setting up retries and failure handling before you need them.
<?php
class ProcessPayment implements ShouldQueue
{
// Simple retry count
public int $tries = 5;
// Exponential backoff: wait 10s, 30s, 60s between retries
public array $backoff = [10, 30, 60];
// Or use a method for dynamic backoff
public function backoff(): array
{
return [1, 5, 10, 30, 60]; // Increasing delays
}
// Keep retrying until this time
public function retryUntil(): DateTime
{
return now()->addHours(12);
}
// Maximum exceptions before marking as failed
public int $maxExceptions = 3;
// Timeout for the job
public int $timeout = 120;
// Delete job if model is missing (soft deletes)
public bool $deleteWhenMissingModels = true;
// Jobs that should run only once
public bool $uniqueFor = 3600; // Unique for 1 hour
public function uniqueId(): string
{
return $this->payment->id;
}
}
<?php
class ProcessPayment implements ShouldQueue
{
public function handle(): void
{
try {
$this->processPaymentWithProvider();
} catch (PaymentProviderException $e) {
// Specific exception handling - maybe don't retry
$this->fail($e);
return;
}
// Other exceptions will trigger automatic retry
}
/**
* Handle a job failure after all retries exhausted.
*/
public function failed(Throwable $exception): void
{
// Log the failure
Log::error('Payment processing failed permanently', [
'payment_id' => $this->payment->id,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Update payment status
$this->payment->update(['status' => 'failed']);
// Notify relevant parties
$this->payment->user->notify(new PaymentFailed($this->payment));
// Alert operations team
Notification::route('slack', config('services.slack.ops_channel'))
->notify(new PaymentFailedAlert($this->payment, $exception));
// Create support ticket
SupportTicket::create([
'type' => 'payment_failure',
'reference_id' => $this->payment->id,
'description' => $exception->getMessage(),
]);
}
}
// Global failed job handler in AppServiceProvider
use Illuminate\Support\Facades\Queue;
Queue::failing(function (JobFailed $event) {
// Log all job failures centrally
Log::channel('failed-jobs')->error('Job failed', [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'job' => $event->job->resolveName(),
'exception' => $event->exception->getMessage(),
]);
});
# List all failed jobs
php artisan queue:failed
# Retry a specific failed job by ID
php artisan queue:retry 5
# Retry all failed jobs
php artisan queue:retry all
# Retry jobs that failed in the last 24 hours
php artisan queue:retry all --range=0-24
# Delete a specific failed job
php artisan queue:forget 5
# Delete all failed jobs
php artisan queue:flush
# Prune old failed jobs (keep last 48 hours)
php artisan queue:prune-failed --hours=48
# Add to scheduler for automatic cleanup
// app/Console/Kernel.php
$schedule->command('queue:prune-failed --hours=168')->daily();
Monitoring with Laravel Horizon
Plain queue:work gives you no visibility. You can't easily see how many jobs are waiting, which ones are slow, or what just failed. Horizon adds a dashboard and lets you define worker pools in code, but only for Redis queues.
Install Horizon
Install via Composer and publish configuration: composer require laravel/horizon then php artisan horizon:install
Configure Supervisors
Define worker pools in config/horizon.php with different queues, processes, and balancing strategies.
Start Horizon
Run php artisan horizon to start all configured workers. Access the dashboard at /horizon.
Deploy with Supervisor
Use Supervisor to keep Horizon running in production and restart it after deployments.
<?php
return [
'domain' => env('HORIZON_DOMAIN'),
'path' => 'horizon',
// Redis connection to use
'use' => 'default',
// Prefix for Horizon keys in Redis
'prefix' => env('HORIZON_PREFIX', 'horizon:'),
// Dashboard route middleware
'middleware' => ['web', 'auth', 'can:viewHorizon'],
// Queue worker configuration
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto', // auto, simple, or false
'autoScalingStrategy' => 'time', // time or size
'maxProcesses' => 10,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
// High priority jobs - always process immediately
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high', 'notifications'],
'balance' => 'false', // Fixed number of workers
'processes' => 5,
'tries' => 3,
'timeout' => 30,
],
// Default queue with auto-scaling
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'balanceMaxShift' => 1, // Max processes to add/remove at once
'balanceCooldown' => 3, // Seconds between scaling decisions
],
// Low priority, long-running jobs
'supervisor-low' => [
'connection' => 'redis',
'queue' => ['low', 'reports', 'exports'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 3,
'tries' => 1,
'timeout' => 1800, // 30 minutes
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'false',
'processes' => 3,
],
],
],
];
Outside the local environment, Horizon returns a 403 to everyone until you define a viewHorizon gate. Put it in AuthServiceProvider (or Horizon's own HorizonServiceProvider):
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, ['admin@example.com']);
});
Production Deployment
Workers are long-running processes, so something has to start them, restart them when they crash, and restart them again on every deploy. Supervisor is the usual choice on a plain VPS.
Supervisor Configuration
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log
stopwaitsecs=3600
stopsignal=SIGTERM
; For queue:work without Horizon
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
Deployment Script
#!/bin/bash
# Put application in maintenance mode
php artisan down
# Pull latest code
git pull origin main
# Install dependencies
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and rebuild caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Restart Horizon gracefully (finish current jobs, then restart)
php artisan horizon:terminate
# Or for queue:work, restart via Supervisor
# supervisorctl restart laravel-worker:*
# Bring application back online
php artisan up
echo "Deployment complete!"
Use horizon:terminate or queue:restart so workers finish their current job before exiting. A SIGKILL stops a job halfway through, which can leave your data half-updated. That's also why stopwaitsecs in the Supervisor config should be longer than your longest job.
Production Checklist
Queue Production Checklist
- ☐ Process Manager: Supervisor or systemd keeping workers alive
- ☐ Monitoring: Horizon dashboard or external monitoring (Datadog, NewRelic)
- ☐ Alerting: Alerts for queue backlogs, high failure rates, and worker deaths
- ☐ Failed Job Handling: Strategy for retrying or investigating failed jobs
- ☐ Log Rotation: Prevent worker logs from filling up disk
- ☐ Memory Limits: Set
--memoryflag to restart workers before OOM - ☐ Timeout Configuration: Appropriate timeouts for different job types
- ☐ Graceful Restarts: Deployment scripts use
horizon:terminate - ☐ Redis Persistence: Configure Redis AOF/RDB for queue durability
- ☐ Backup Strategy: Regular backups of failed_jobs table
Best Practices and Tips
Job Design Best Practices
- Keep jobs small and focused: One job = one responsibility. Break complex workflows into chains.
- Make jobs idempotent: Jobs might run multiple times. Design them to produce the same result regardless.
- Pass IDs, not objects: Serialize model IDs and refetch in
handle()to get fresh data. - Handle missing models gracefully: Use
$deleteWhenMissingModels = trueor check existence. - Set appropriate timeouts: Know how long your job takes and set timeout accordingly.
- Use typed properties: PHP 8+ typed properties make job payloads clear and validated.
Queue Organization Strategies
- Separate queues by priority:
high,default,lowwith different worker counts. - Separate queues by type:
emails,exports,notificationsfor different SLAs. - Separate queues by resource: CPU-intensive vs I/O-bound jobs on different workers.
- Use queue priority workers:
queue:work --queue=high,default,lowprocesses high first.
Common Pitfalls to Avoid
- Serializing large payloads: Don't pass entire collections or large files in job constructors.
- Not handling exceptions: Uncaught exceptions cause silent failures. Use try/catch and
failed(). - Ignoring memory leaks: Long-running workers accumulate memory. Set
--max-jobsor--max-time. - Forgetting queue:restart: After code changes, workers still run old code until restarted.
- Not monitoring queue depth: Backlogs can grow silently. Set up alerts for queue size.
When I'd reach for a queue (and when I wouldn't)
I'd queue anything that talks to a third party (mail, payment webhooks, external APIs), anything CPU-heavy like image or PDF work, and anything the user doesn't need to see finish before the page loads. Those are the cases where a slow or flaky dependency would otherwise become your response time.
I wouldn't queue work whose result the user needs on the very next screen. If they submit a form and expect to see the saved data, doing it inline is simpler and avoids "why isn't it there yet?" bugs. For tiny jobs on a small app, dispatchAfterResponse() is often enough and doesn't need a worker at all.
Also keep in mind what a queue adds: another process to supervise, another place for things to fail silently, and code that now runs out of band, so it can't rely on the session or the current request. If you can't commit to monitoring the queue, a backlog of stuck jobs is worse than a slightly slow request. And if you're starting with the database driver, that's fine for low volume, but plan to move to Redis before the jobs table becomes a source of lock contention.