Copied!
Programming
Laravel
PHP

Migrating Legacy Laravel Jobs to Modern Queue Architecture

Migrating Legacy Laravel Jobs to Modern Queue Architecture
Shahroz Javed
Apr 07, 2026 . 163 views

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

📑 On This Page