Architecture

Job Standards

When to Use Jobs

  • Long-running tasks (API calls, file processing, report generation)
  • Tasks that can fail and need retry logic
  • Tasks that should run asynchronously
  • Tasks that need rate limiting
  • Sending emails and notifications
  • Processing uploads or imports

File Organization

app/Jobs/
├── ProcessOrder.php
├── SendWelcomeEmail.php
├── GenerateReport.php
├── Import/
│   ├── ImportUsers.php
│   └── ImportProducts.php
├── Export/
│   └── ExportOrdersCsv.php
└── Middleware/
    ├── RateLimited.php
    └── WithoutOverlapping.php

Basic Structure

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use App\Services\PaymentService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

final class ProcessOrder implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(
        public Order $order,
    ) {}

    public function handle(PaymentService $paymentService): void
    {
        $paymentService->charge($this->order);
    }
}

Job Configuration

Retries and Timeout

final class ProcessOrder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public int $timeout = 120;
}

Backoff Strategy

Use either the property OR the method (method takes precedence if both exist):

// Fixed backoff - wait 60 seconds between retries
public int $backoff = 60;

// Exponential backoff - wait 30s, then 60s, then 120s
public function backoff(): array
{
    return [30, 60, 120];
}

Maximum Exceptions

final class ProcessWebhook implements ShouldQueue
{
    public int $maxExceptions = 3;
}

Retry Until

final class ProcessOrder implements ShouldQueue
{
    public function retryUntil(): DateTime
    {
        return now()->addHours(24);
    }
}

Dispatching Jobs

Basic Dispatching

use App\Jobs\ProcessOrder;

// Dispatch to default queue
ProcessOrder::dispatch($order);

// Using dispatch helper
dispatch(new ProcessOrder($order));

Delayed Dispatch

// Delay by time
ProcessOrder::dispatch($order)->delay(now()->addMinutes(10));

// Delay until specific time
ProcessOrder::dispatch($order)->delay(now()->endOfDay());

Queue and Connection

// Specific queue
ProcessOrder::dispatch($order)->onQueue('orders');

// Specific connection
ProcessOrder::dispatch($order)->onConnection('redis');

// Both
ProcessOrder::dispatch($order)
    ->onConnection('redis')
    ->onQueue('high-priority');

Synchronous Dispatch

// Run immediately without queue (useful for testing)
ProcessOrder::dispatchSync($order);

// Or conditionally
if (app()->environment('local')) {
    ProcessOrder::dispatchSync($order);
} else {
    ProcessOrder::dispatch($order);
}

Conditional Dispatch

// Dispatch if condition is true
ProcessOrder::dispatchIf($order->isPaid(), $order);

// Dispatch unless condition is true
ProcessOrder::dispatchUnless($order->isCancelled(), $order);

Job Chains

Execute jobs sequentially, stopping if one fails:

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new ProcessPayment($order),
    new UpdateInventory($order),
    new SendOrderConfirmation($order),
    new NotifyWarehouse($order),
])->dispatch();

// With error handling
Bus::chain([
    new ProcessPayment($order),
    new UpdateInventory($order),
    new SendOrderConfirmation($order),
])->catch(function (Throwable $e) use ($order): void {
    Log::error('Order processing chain failed', [
        'order_id' => $order->id,
        'error' => $e->getMessage(),
    ]);
})->dispatch();

// On specific queue
Bus::chain([...])->onQueue('orders')->dispatch();

Job Batches

Execute jobs in parallel with batch tracking:

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ImportChunk($users->slice(0, 100)),
    new ImportChunk($users->slice(100, 100)),
    new ImportChunk($users->slice(200, 100)),
])
->then(function (Batch $batch): void {
    Log::info('All jobs completed successfully');
})
->catch(function (Batch $batch, Throwable $e): void {
    Log::error('Batch job failed', ['error' => $e->getMessage()]);
})
->finally(function (Batch $batch): void {
    Log::info('Batch finished', [
        'total' => $batch->totalJobs,
        'failed' => $batch->failedJobs,
    ]);
})
->name('Import Users')
->onQueue('imports')
->dispatch();

// Get batch ID for tracking
$batchId = $batch->id;

Batchable Jobs

use Illuminate\Bus\Batchable;

final class ImportChunk implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle(): void
    {
        // Check if batch was cancelled
        if ($this->batch()->cancelled()) {
            return;
        }

        // Process chunk...

        // Add more jobs to batch if needed
        $this->batch()->add([
            new ProcessImportedRecord($record),
        ]);
    }
}

Unique Jobs

Prevent duplicate jobs from being queued:

use Illuminate\Contracts\Queue\ShouldBeUnique;

final class ProcessOrder implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Order $order,
    ) {}

    /**
     * Unique ID for the job.
     */
    public function uniqueId(): string
    {
        return (string) $this->order->id;
    }

    /**
     * Seconds to maintain uniqueness lock.
     */
    public function uniqueFor(): int
    {
        return 60;
    }
}

Job Middleware

Rate Limiting Middleware

<?php

declare(strict_types=1);

namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\RateLimiter;

final class RateLimited
{
    public function __construct(
        private string $key,
        private int $maxAttempts = 10,
        private int $decaySeconds = 60,
    ) {}

    public function handle(object $job, Closure $next): void
    {
        $key = $this->key . ':' . ($job->uniqueId ?? get_class($job));

        if (RateLimiter::tooManyAttempts($key, $this->maxAttempts)) {
            $job->release($this->decaySeconds);

            return;
        }

        RateLimiter::hit($key, $this->decaySeconds);

        $next($job);
    }
}

Apply Middleware to Job

final class ProcessOrder implements ShouldQueue
{
    /**
     * @return array<object>
     */
    public function middleware(): array
    {
        return [
            new RateLimited('orders', maxAttempts: 30, decaySeconds: 60),
            new WithoutOverlapping('process-order-' . $this->order->id),
        ];
    }
}

Built-in Middleware

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        // Rate limit using named limiter
        new RateLimited('orders'),

        // Prevent overlapping
        (new WithoutOverlapping($this->order->id))
            ->releaseAfter(60)
            ->expireAfter(300),

        // Throttle on exceptions
        (new ThrottlesExceptions(maxAttempts: 3, decayMinutes: 5))
            ->backoff(5),
    ];
}

Failed Jobs

Handle Failures

final class ProcessOrder implements ShouldQueue
{
    public function handle(PaymentService $payment): void
    {
        $payment->charge($this->order);
    }

    /**
     * Handle job failure.
     */
    public function failed(?Throwable $exception): void
    {
        Log::error('Order processing failed', [
            'order_id' => $this->order->id,
            'error' => $exception?->getMessage(),
        ]);

        // Notify admin
        Notification::route('mail', config('mail.admin'))
            ->notify(new JobFailedNotification($this, $exception));

        // Update order status
        $this->order->update(['status' => OrderStatus::Failed]);
    }
}

Manual Failure

public function handle(): void
{
    if (!$this->isValid()) {
        $this->fail('Invalid data provided');

        return;
    }

    // Or fail with exception
    $this->fail(new InvalidDataException('Missing required fields'));
}

Testing Jobs

Test Job Is Dispatched

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Jobs\ProcessOrder;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

final class OrderTest extends TestCase
{
    public function test_order_dispatches_processing_job(): void
    {
        Queue::fake();

        $order = Order::factory()->create();

        // Trigger action that dispatches job
        $this->post(route('orders.process', $order));

        Queue::assertPushed(ProcessOrder::class, function ($job) use ($order) {
            return $job->order->id === $order->id;
        });
    }

    public function test_job_dispatched_on_correct_queue(): void
    {
        Queue::fake();

        ProcessOrder::dispatch($order);

        Queue::assertPushedOn('orders', ProcessOrder::class);
    }
}

Test Job Logic

public function test_process_order_charges_payment(): void
{
    $order = Order::factory()->create(['total' => 100]);
    $paymentService = Mockery::mock(PaymentService::class);
    $paymentService->shouldReceive('charge')
        ->once()
        ->with($order)
        ->andReturn(true);

    $job = new ProcessOrder($order);
    $job->handle($paymentService);

    // Assert side effects
    $this->assertTrue($order->fresh()->is_paid);
}

Best Practices

Keep Jobs Small and Focused

// Good - Single responsibility
final class SendOrderConfirmation implements ShouldQueue
{
    public function handle(Mailer $mailer): void
    {
        $mailer->send(new OrderConfirmationMail($this->order));
    }
}

// Bad - Too many responsibilities
final class ProcessOrder implements ShouldQueue
{
    public function handle(): void
    {
        $this->chargePayment();
        $this->updateInventory();
        $this->sendConfirmation();
        $this->notifyWarehouse();
        $this->updateAnalytics();
    }
}

Use Dependency Injection in handle()

// Good - Inject dependencies
public function handle(
    PaymentService $payment,
    InventoryService $inventory,
): void {
    $payment->charge($this->order);
    $inventory->reserve($this->order->items);
}

// Bad - Resolve manually
public function handle(): void
{
    $payment = app(PaymentService::class);
    $payment->charge($this->order);
}

Store Minimal Data

// Good - Store only IDs, fetch fresh data
final class ProcessOrder implements ShouldQueue
{
    public function __construct(
        public Order $order,  // Serialized to ID
    ) {}

    public function handle(): void
    {
        // $this->order is fresh from database
        $this->order->process();
    }
}

// Bad - Store large data structures
final class ProcessOrder implements ShouldQueue
{
    public function __construct(
        public array $orderData,  // Large array serialized
        public array $items,
        public array $userProfile,
    ) {}
}

Handle Idempotency

public function handle(): void
{
    // Check if already processed
    if ($this->order->isProcessed()) {
        return;
    }

    // Or use database transactions
    DB::transaction(function (): void {
        $this->order->lockForUpdate();

        if ($this->order->isProcessed()) {
            return;
        }

        $this->processOrder();
    });
}

Set Appropriate Timeouts

// Short task
final class SendEmail implements ShouldQueue
{
    public int $timeout = 30;
}

// Long task
final class GenerateReport implements ShouldQueue
{
    public int $timeout = 600;  // 10 minutes
}

// API call with retry
final class SyncToExternalService implements ShouldQueue
{
    public int $timeout = 60;
    public int $tries = 3;
    public int $backoff = 30;
}