Copied!
Programming
Laravel
PHP

Laravel Delayed Jobs & Scheduler Integration – Complete Guide

Laravel Delayed Jobs & Scheduler Integration – Complete Guide
Shahroz Javed
Mar 26, 2026 . 93 views

How delay() Works Internally

When you dispatch a job with ->delay(), the job is stored in the queue immediately — it's not held in memory waiting. The available_at timestamp is set to the future time. Workers check this timestamp and skip jobs that aren't yet available, returning them to the invisible "delayed" state until their time comes.

// All three are equivalent — pick the most readable for your context
SendReminderEmail::dispatch($user)->delay(now()->addMinutes(30));
SendReminderEmail::dispatch($user)->delay(1800);  // seconds
SendReminderEmail::dispatch($user)->delay(Carbon::now()->addDay());
⚠️ Delayed jobs count against your queue size immediately. If you delay 10,000 jobs for tomorrow, they're all in the queue table right now — just not being processed yet. Plan your storage accordingly.

How Available_at Works per Driver

  • Database — stores available_at as a Unix timestamp. Workers run WHERE available_at <= NOW().

  • Redis — stores delayed jobs in a sorted set with the available timestamp as the score. Horizon/workers use ZRANGEBYSCORE to pick up ready jobs.

  • SQS — uses SQS's native Delay Seconds parameter (max 15 minutes). For longer delays, you must simulate delay with a database or Redis marker.

Delay Patterns & Real Use Cases

Pattern 1: Drip Email Onboarding

// On user registration, schedule the whole onboarding sequence at once
public function registered(User $user): void
{
    SendWelcomeEmail::dispatch($user);                                           // immediately
    SendGettingStartedTips::dispatch($user)->delay(now()->addDay());             // day 1
    SendFirstFeatureHighlight::dispatch($user)->delay(now()->addDays(3));        // day 3
    SendCheckInEmail::dispatch($user)->delay(now()->addWeek());                  // day 7
    SendUpgradePrompt::dispatch($user)->delay(now()->addDays(14));               // day 14
}

Pattern 2: Abandon Cart Recovery

// When user adds to cart, schedule a reminder if they don't complete
public function itemAddedToCart(Cart $cart): void
{
    // Cancel any previous reminder for this cart
    $this->cancelPreviousReminders($cart);

    // Schedule new reminder — will be cancelled if they complete the purchase
    $jobId = SendAbandonCartEmail::dispatch($cart)->delay(now()->addHour())->getJobId();
    cache()->put("cart_reminder_{$cart->id}", $jobId, now()->addHours(2));
}

public function orderCompleted(Order $order): void
{
    // Cancel the abandon cart reminder — they bought!
    $jobId = cache()->get("cart_reminder_{$order->cart_id}");
    if ($jobId) {
        // Note: cancelling delayed jobs requires driver-specific approach
        DB::table('jobs')->where('id', $jobId)->delete();  // database driver
    }
}

Pattern 3: Scheduled Subscription Warnings

// When subscription is created, queue expiry warnings
public function subscriptionCreated(Subscription $subscription): void
{
    $expiresAt = $subscription->expires_at;

    // 7-day warning
    if ($expiresAt->subDays(7)->isFuture()) {
        SendExpiryWarning::dispatch($subscription, 7)
            ->delay($expiresAt->subDays(7));
    }

    // 1-day warning
    if ($expiresAt->subDay()->isFuture()) {
        SendExpiryWarning::dispatch($subscription, 1)
            ->delay($expiresAt->subDay());
    }

    // Expiry action
    HandleSubscriptionExpiry::dispatch($subscription)
        ->delay($expiresAt);
}

Scheduler vs Queue – When to Use Which

Developers often confuse when to use the Laravel Scheduler (schedule:run) vs the Queue (queue:work). They solve different problems:

  • Scheduler — runs tasks on a time-based calendar (every hour, every day at 2am). Think: recurring maintenance tasks, report generation, cleanup jobs. One execution per schedule cycle.

  • Queue — processes tasks triggered by events (user action, webhook, API call). Think: send this email, process this payment. As many executions as there are triggers.

  • Both together — the scheduler triggers queue jobs. The scheduler decides when; the queue handles the what at scale. This is the most powerful pattern.

Scheduler Dispatching Queue Jobs

Using the scheduler to dispatch queue jobs combines the scheduling precision of cron with the parallel processing power of queues.

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    // Every night at 2am, dispatch jobs for all users who need a weekly digest
    $schedule->job(new DispatchWeeklyDigests)->weekly()->mondays()->at('08:00');

    // Every hour, sync stale CRM data
    $schedule->job(new SyncStaleCrmContacts)->hourly();

    // Every 5 minutes, dispatch a batch for pending report generation
    $schedule->call(function () {
        Report::where('status', 'pending')
            ->where('scheduled_for', '<=', now())
            ->cursor()
            ->each(fn ($report) => GenerateReport::dispatch($report)->onQueue('reports'));
    })->everyFiveMinutes()->withoutOverlapping();

    // Daily cleanup — queue a batch to prune expired data
    $schedule->job(new PruneExpiredSessions)->daily()->at('03:00')->onQueue('maintenance');
}

Self-Scheduling Jobs

A self-scheduling job dispatches itself at the end of handle() — creating a perpetual background loop without cron. Best for polling-style tasks.

class PollPaymentStatus implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;  // Don't retry on failure — it will self-reschedule
    private const INTERVAL = 30;  // seconds between polls

    public function __construct(protected Payment $payment) {}

    public function handle(): void
    {
        // Don't poll if payment is in a terminal state
        if (in_array($this->payment->fresh()->status, ['paid', 'failed', 'cancelled'])) {
            return;  // stop polling — no reschedule
        }

        $status = PaymentGateway::checkStatus($this->payment->gateway_id);

        if ($status === 'completed') {
            $this->payment->update(['status' => 'paid', 'paid_at' => now()]);
            $this->payment->order->customer->notify(new PaymentConfirmed($this->payment));
            return;  // done — no reschedule
        }

        if ($status === 'failed') {
            $this->payment->update(['status' => 'failed']);
            return;
        }

        // Still pending — reschedule in 30 seconds
        static::dispatch($this->payment->fresh())->delay(now()->addSeconds(self::INTERVAL));
    }
}

// Start polling when payment is created
public function createPayment(Order $order): Payment
{
    $payment = Payment::create([...]);
    PollPaymentStatus::dispatch($payment)->delay(now()->addSeconds(10));
    return $payment;
}

Time-Based Workflow Patterns

Deadline-Driven Jobs

class EscalateUnresolvedTicket implements ShouldQueue
{
    public function __construct(protected Ticket $ticket) {}

    public function handle(): void
    {
        $ticket = $this->ticket->fresh();

        // Only escalate if still unresolved after the deadline
        if ($ticket->status !== 'open') {
            return;  // resolved in the meantime — skip
        }

        $ticket->escalate();
        $ticket->assignedAgent->supervisor->notify(new TicketEscalated($ticket));
    }
}

// When ticket is created, schedule escalation 4 hours later
public function created(Ticket $ticket): void
{
    EscalateUnresolvedTicket::dispatch($ticket)->delay(now()->addHours(4));
}

Conclusion

  • delay() stores jobs in the queue immediately with a future available_at — not held in memory

  • Use delayed dispatch for drip emails, abandon cart recovery, subscription expiry workflows

  • The Scheduler triggers the when; the Queue handles the what — combine them for maximum power

  • Self-scheduling jobs create controlled polling loops without cron, with built-in stop conditions

  • Always re-fetch model state inside handle() of delayed jobs — the world may have changed since dispatch

📑 On This Page