Copied!
Programming
Laravel
PHP

Advanced Laravel Job Patterns – Self-Scheduling, Pipelines, Event-Driven Chains

Advanced Laravel Job Patterns – Self-Scheduling, Pipelines, Event-Driven Chains
Shahroz Javed
Mar 22, 2026 . 41 views

Self-Scheduling Jobs

A self-scheduling job re-dispatches itself at the end of its handle() method with a delay. This creates a recurring background process without cron jobs. It's perfect for tasks that need to run continuously but on a controlled interval.

// App\Jobs\PollExternalApiForUpdates.php
class PollExternalApiForUpdates implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 30;

    private const POLL_INTERVAL_SECONDS = 60;

    public function handle(ExternalApiService $api): void
    {
        try {
            $updates = $api->getLatestUpdates(since: cache('last_poll_at', now()->subMinutes(5)));

            foreach ($updates as $update) {
                ProcessExternalUpdate::dispatch($update)->onQueue('updates');
            }

            cache()->put('last_poll_at', now(), ttl: 3600);

        } finally {
            // Always reschedule — even if we had errors
            // Use finally so the polling never stops due to exceptions
            static::dispatch()->delay(now()->addSeconds(self::POLL_INTERVAL_SECONDS));
        }
    }
}

// Start the polling loop from a command or seeder
// php artisan tinker → PollExternalApiForUpdates::dispatch()
⚠️ Self-scheduling jobs need a guard to prevent runaway dispatch. If the job keeps failing and retrying, AND rescheduling, you can flood your queue. Always use finally carefully and consider adding a stop condition (e.g. a cache flag to pause polling).

Self-Scheduling with Stop/Start Control

public function handle(): void
{
    // Check if polling is paused (via admin panel or feature flag)
    if (cache()->get('polling_paused', false)) {
        // Don't reschedule — polling is stopped until manually restarted
        \Log::info('Polling paused. Job will not reschedule.');
        return;
    }

    // ... do work ...

    static::dispatch()->delay(now()->addMinutes(1));
}

The Job Pipeline Pattern

The pipeline pattern passes a single payload through a series of independent transformations. Each stage is a separate job, but unlike chaining, each stage can be developed and tested in isolation, and stages can run in parallel if they're independent.

// Imagine a video processing pipeline:
// Upload → Transcode → Generate Thumbnail → Extract Audio → Index for Search → Notify User

// App\Pipelines\VideoProcessingPipeline.php
class VideoProcessingPipeline
{
    public static function dispatch(Video $video): void
    {
        Bus::chain([
            new ValidateVideoFile($video),
            new TranscodeVideo($video),

            // These two can run in parallel — use a batch inside the chain!
            function () use ($video) {
                Bus::batch([
                    new GenerateThumbnails($video),
                    new ExtractAudioTrack($video),
                ])->allowFailures()->dispatch();
            },

            new IndexVideoForSearch($video),
            new NotifyVideoPublished($video),
        ])
        ->catch(function (\Throwable $e) use ($video) {
            $video->update(['status' => 'processing_failed', 'error' => $e->getMessage()]);
            $video->uploader->notify(new VideoProcessingFailed($video));
        })
        ->onQueue('video-pipeline')
        ->dispatch();

        $video->update(['status' => 'processing']);
    }
}

// In your controller — clean, single call
public function publish(Video $video): JsonResponse
{
    VideoProcessingPipeline::dispatch($video);

    return response()->json(['message' => 'Video is being processed.', 'status' => 'processing']);
}

Event-Driven Job Chains

Instead of dispatching jobs directly, use Laravel Events where listeners dispatch jobs. This decouples your business logic from your queue infrastructure and makes the system more extensible.

// App\Events\OrderPlaced.php
class OrderPlaced
{
    public function __construct(public readonly Order $order) {}
}

// App\Listeners\HandleOrderPlaced.php — dispatches jobs
class HandleOrderPlaced implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public function handle(OrderPlaced $event): void
    {
        $order = $event->order;

        Bus::chain([
            new ReserveInventory($order),
            new ProcessPayment($order),
            new GenerateInvoice($order),
            new SendOrderConfirmation($order),
            new NotifyFulfillmentTeam($order),
        ])->dispatch();
    }
}

// App\Providers\EventServiceProvider.php
protected $listen = [
    OrderPlaced::class => [
        HandleOrderPlaced::class,
        UpdateAnalytics::class,      // another listener, independent
        SyncToErp::class,            // another listener, independent
    ],
];

// In your controller — just fire the event
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = Order::create($request->validated());

    event(new OrderPlaced($order));  // all listeners fire asynchronously

    return response()->json(['order_id' => $order->id], 201);
}

The power here: adding a new action when an order is placed (e.g. sending to a new shipping partner) means adding one new listener class — no changes to the controller, no changes to existing listeners. Open/Closed Principle in action.

Conditional Job Chaining

Real workflows have branches — "if payment fails, don't ship; if user is premium, skip verification step." You can handle this inside a job's handle() method by appending to the chain conditionally.

// App\Jobs\ProcessPayment.php
class ProcessPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

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

        $result = PaymentGateway::charge($order);

        if ($result->successful()) {
            $order->update(['status' => 'paid', 'transaction_id' => $result->transactionId]);

            // Conditionally add next step to the chain
            if ($order->requiresFraudReview()) {
                $this->chain([
                    new FraudReviewJob($order),
                    new FulfillOrder($order),
                    new SendConfirmation($order),
                ]);
            } else {
                // No fraud review needed — go straight to fulfillment
                $this->chain([
                    new FulfillOrder($order),
                    new SendConfirmation($order),
                ]);
            }

        } else {
            $order->update(['status' => 'payment_failed']);

            // Don't continue the chain — dispatch failure jobs instead
            SendPaymentFailedEmail::dispatch($order);
            $this->delete();  // remove this job cleanly
        }
    }
}

Dynamic Batch Building

Sometimes you don't know at dispatch time how many jobs a batch will need. Build the batch dynamically based on the data you're processing.

// App\Jobs\ProcessMonthlyReports.php
class ProcessMonthlyReports implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(protected int $year, protected int $month) {}

    public function handle(): void
    {
        // Dynamically determine which accounts need reports
        $accounts = Account::where('plan', '!=', 'free')
            ->where('report_enabled', true)
            ->whereHas('transactions', function ($q) {
                $q->whereYear('created_at', $this->year)
                  ->whereMonth('created_at', $this->month);
            })
            ->get();

        if ($accounts->isEmpty()) {
            \Log::info('No accounts to report for ' . $this->year . '/' . $this->month);
            return;
        }

        // Build batch dynamically
        $jobs = $accounts->map(fn ($account) =>
            new GenerateAccountReport($account, $this->year, $this->month)
        )->all();

        Bus::batch($jobs)
            ->name("Monthly Reports {$this->year}/{$this->month}")
            ->allowFailures()
            ->then(function (Batch $batch) {
                \Log::info("All {$batch->totalJobs} reports generated successfully.");
                // Send a summary report to finance team
                SendFinanceSummary::dispatch($this->year, $this->month);
            })
            ->catch(function (Batch $batch, \Throwable $e) {
                \Log::error("Report batch had failures: " . $e->getMessage());
            })
            ->finally(function (Batch $batch) {
                \Log::info("Report batch finished. Failed: {$batch->failedJobs}");
            })
            ->onQueue('reports')
            ->dispatch();
    }
}

The Saga Pattern – Distributed Transactions

When a workflow spans multiple services and any step can fail, you need compensating transactions — the ability to undo previous steps when a later step fails. This is the Saga pattern, and it's essential for reliable multi-step business processes.

// App\Jobs\BookFlightSaga.php
// Saga: Reserve seat → Charge card → Send confirmation
// Compensation: If charging fails → Release seat reservation

class ReserveSeat implements ShouldQueue
{
    public function handle(): void
    {
        $reservation = FlightApi::reserveSeat($this->flightId, $this->seatNumber);
        $this->booking->update([
            'reservation_id' => $reservation->id,
            'status' => 'seat_reserved',
        ]);
    }

    public function failed(\Throwable $e): void
    {
        // Can't even reserve the seat — cancel immediately
        $this->booking->update(['status' => 'failed']);
        SendBookingFailedEmail::dispatch($this->booking);
    }
}

class ChargeForFlight implements ShouldQueue
{
    public int $tries = 3;

    public function handle(): void
    {
        $booking = $this->booking->fresh();
        PaymentGateway::charge($booking->total, $booking->payment_method);
        $booking->update(['status' => 'charged']);
    }

    public function failed(\Throwable $e): void
    {
        $booking = $this->booking->fresh();

        // COMPENSATING TRANSACTION — undo the previous step
        if ($booking->reservation_id) {
            FlightApi::cancelReservation($booking->reservation_id);
        }

        $booking->update(['status' => 'payment_failed']);
        SendPaymentFailedEmail::dispatch($booking);
    }
}

// Orchestrate the saga as a chain
// If ChargeForFlight fails, its failed() method handles compensation
Bus::chain([
    new ReserveSeat($booking),
    new ChargeForFlight($booking),
    new SendBookingConfirmation($booking),
])->dispatch();

Job Decorator Pattern

The decorator pattern wraps a job with additional behavior without changing the job class. This is useful for cross-cutting concerns like tenancy, logging, or feature flags that you want to apply to specific job dispatches without modifying the jobs themselves.

// App\Jobs\Decorators\WithTenantContext.php
// Wraps any job to ensure it runs in the correct tenant context

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

    public function __construct(
        protected object $innerJob,
        protected int    $tenantId
    ) {}

    public function handle(): void
    {
        // Set the tenant context before running the inner job
        $tenant = Tenant::find($this->tenantId);
        $tenant->configure()->use();  // switch database, cache, storage to this tenant

        try {
            // Resolve and run the inner job via the container
            app()->call([$this->innerJob, 'handle']);
        } finally {
            // Always reset tenant context, even on failure
            Tenant::resetToDefault();
        }
    }
}

// Usage — wrap any job to run in a specific tenant context
WithTenantContext::dispatch(
    new SendInvoiceEmail($invoice),
    tenantId: $tenant->id
)->onQueue('tenant-notifications');

// Or create a helper for cleaner syntax
class Job
{
    public static function forTenant(int $tenantId, object $job): WithTenantContext
    {
        return new WithTenantContext($job, $tenantId);
    }
}

WithTenantContext::dispatch(Job::forTenant($tenant->id, new SendInvoiceEmail($invoice)));

Conclusion

Advanced job patterns transform your queue system from simple background task runner into a robust, distributed workflow engine. Here's a summary of when to use each:

  • Self-Scheduling Jobs — polling external APIs, background sync tasks that need controlled intervals

  • Pipeline Pattern — multi-step data transformation where each step is testable independently

  • Event-Driven Chains — decoupled business logic where new behaviors can be added without modifying existing code

  • Conditional Chaining — workflows with business-logic branches that determine the next step at runtime

  • Dynamic Batches — when the size of the work is only known at processing time

  • Saga Pattern — multi-step workflows that span external services, requiring compensating transactions on failure

  • Job Decorator — injecting cross-cutting context (tenancy, logging) into jobs without modifying them

📑 On This Page