Assess What Needs to Move
Before touching any code, audit your application for synchronous work that blocks requests. These are your highest-priority migration targets:
// Signs that something should be in a queue:
// ✓ It calls Mail::send() or Notification::send()
// ✓ It calls an external HTTP API (Stripe, Twilio, HubSpot, etc.)
// ✓ It reads or writes large files
// ✓ It does image/video/PDF processing
// ✓ It runs database operations over large datasets
// ✓ It's a cron job that runs over thousands of records
// ✓ Controllers that take > 500ms to respond
// ✓ Scheduled commands that run for more than a few seconds
Profiling Tool – Find Slow Controller Actions
// Add this middleware temporarily to measure response times
// app/Http/Middleware/MeasureResponseTime.php
class MeasureResponseTime
{
public function handle(Request $request, \Closure $next): Response
{
$start = microtime(true);
$response = $next($request);
$duration = round((microtime(true) - $start) * 1000);
if ($duration > 500) {
\Log::warning('Slow response detected', [
'url' => $request->url(),
'method' => $request->method(),
'duration_ms' => $duration,
]);
}
return $response->header('X-Response-Time', $duration . 'ms');
}
}
Pattern 1 – Sync Controller Logic to Queue
The most common migration: move work that currently runs synchronously in a controller into a queued job.
// ❌ BEFORE: Slow controller — user waits 3+ seconds
class OrderController extends Controller
{
public function store(StoreOrderRequest $request): JsonResponse
{
$order = Order::create($request->validated());
// All of this blocks the HTTP response:
Mail::to($order->customer)->send(new OrderConfirmation($order));
Stripe::createInvoice($order);
HubspotService::syncContact($order->customer);
SlackService::notifyTeam("New order #{$order->id}");
return response()->json($order, 201);
}
}
// ✅ AFTER: Fast controller — user gets response in <100ms
class OrderController extends Controller
{
public function store(StoreOrderRequest $request): JsonResponse
{
$order = Order::create($request->validated());
// Everything deferred to the queue:
event(new OrderPlaced($order));
// Or dispatch jobs directly:
// Bus::chain([
// new SendOrderConfirmation($order),
// new CreateStripeInvoice($order),
// new SyncToHubspot($order->customer),
// new NotifySlack($order),
// ])->dispatch();
return response()->json($order, 201);
}
}
Pattern 2 – Cron Jobs to Queue Jobs
Cron jobs that process many records are sequential and can't be parallelised. Move the work to a batch of queue jobs so multiple workers process records simultaneously.
// ❌ BEFORE: Slow cron command — sequential, single-threaded
class SendWeeklyReports extends Command
{
protected $signature = 'reports:send-weekly';
public function handle(): void
{
User::premium()->cursor()->each(function (User $user) {
// Sequential — processes one user at a time
$report = ReportGenerator::generate($user);
Mail::to($user)->send(new WeeklyReport($report));
});
// 10,000 users × 2 seconds each = 5+ hours to complete
}
}
// ✅ AFTER: Fast dispatcher — parallelised via queue batch
class DispatchWeeklyReports extends Command
{
protected $signature = 'reports:dispatch-weekly';
public function handle(): void
{
$jobs = User::premium()
->select('id')
->cursor()
->map(fn ($user) => new SendWeeklyReportToUser($user->id))
->all();
Bus::batch($jobs)
->name('Weekly Reports ' . now()->format('Y-W'))
->allowFailures()
->onQueue('reports')
->dispatch();
$this->info("Dispatched " . count($jobs) . " report jobs.");
// Command finishes in seconds — queue processes in parallel
}
}
// Schedule the dispatcher (not the slow command)
$schedule->command('reports:dispatch-weekly')->mondays()->at('06:00');
Pattern 3 – Breaking Monolith Tasks into Jobs
Large monolithic jobs that do many things are fragile — one failure restarts the entire process. Break them into small, single-responsibility jobs chained together.
// ❌ BEFORE: One giant job — if step 5 fails, steps 1-4 are wasted
class ProcessNewUserOnboarding implements ShouldQueue
{
public int $tries = 3;
public function handle(): void
{
// If any of these fail, the entire job restarts from step 1:
$this->createUserProfile(); // step 1
$this->assignDefaultRole(); // step 2
$this->sendWelcomeEmail(); // step 3
$this->createTrialSubscription(); // step 4
$this->syncToHubspot(); // step 5 ← failure restarts all
$this->sendSlackNotification(); // step 6
}
}
// ✅ AFTER: Chain of small, independent jobs
// Each job is independently retryable — failure only restarts that step
Bus::chain([
new CreateUserProfile($user),
new AssignDefaultRole($user),
new SendWelcomeEmail($user),
new CreateTrialSubscription($user),
new SyncUserToHubspot($user),
new SendSlackOnboardingNotification($user),
])->catch(function (\Throwable $e) use ($user) {
\Log::error("Onboarding failed at step: " . $e->getMessage());
})->dispatch();
Safe Rollout Strategy
Don't migrate everything at once. Use this phased approach to de-risk the migration:
Phase 1: Add Queue Infrastructure (No Code Changes)
# Set up the queue backend without changing any app code
# 1. Configure Redis or database queue driver
# 2. Set up Supervisor with workers
# 3. Verify workers are running and processing test jobs
# 4. Set up monitoring (Horizon or basic alerts)
# Test with a throwaway job
php artisan make:job TestQueueJob
# dispatch it, verify it runs, check logs
Phase 2: Feature Flag Gating
// Migrate one action at a time, behind a feature flag
public function store(StoreOrderRequest $request): JsonResponse
{
$order = Order::create($request->validated());
if (config('features.async_order_emails')) {
SendOrderConfirmation::dispatch($order); // async
} else {
Mail::to($order->customer)->send(new OrderConfirmation($order)); // sync fallback
}
return response()->json($order, 201);
}
// Enable for a small percentage first, then 100% once validated
// config/features.php
'async_order_emails' => env('FEATURE_ASYNC_ORDER_EMAILS', false),
Phase 3: Monitor & Validate
// After each migration, watch for 48 hours:
// 1. Are queue workers processing jobs? (check Horizon or queue:failed)
// 2. Is the failure rate acceptable? (< 1% for transient issues is normal)
// 3. Are response times improved? (check your APM — NewRelic, Datadog)
// 4. Are emails/notifications being received by users? (sanity check)
// 5. Is the failed_jobs table growing or stable?
Infrastructure Checklist Before Migration
Before starting ANY queue migration, verify:
Queue Backend:
✓ Redis or database driver configured in .env
✓ QUEUE_CONNECTION is NOT sync in production
✓ failed_jobs table created (queue:failed-table + migrate)
Workers:
✓ Supervisor installed and configured
✓ Workers start automatically on server reboot (autostart=true)
✓ Workers restart automatically on crash (autorestart=true)
✓ Workers restart after deploy (queue:restart in deploy script)
Storage:
✓ File storage is S3 or shared NFS (not local disk)
✓ Cache driver is Redis (not file) for shared lock/restart signals
Monitoring:
✓ At minimum: scheduler alert when failed_jobs > N
✓ Recommended: Horizon dashboard (Redis) or custom metrics
✓ Alert channel configured (Slack, email, PagerDuty)
Conclusion
Migrating to a proper queue architecture is one of the highest-ROI improvements you can make to a Laravel application. Users feel it immediately — response times drop, reliability increases, and failures are recoverable instead of user-facing.
Audit first — profile response times and identify the slowest controller actions and cron jobs
Migrate sync controller logic to jobs dispatched from events — immediate response time improvement
Replace sequential cron commands with a dispatcher job + batch of parallel queue jobs
Break monolithic jobs into small, chained, single-responsibility jobs for independent retries
Roll out behind feature flags — never migrate and trust; migrate, validate, then remove the flag
Set up the infrastructure before writing a single line of job code