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;
}