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