# Laravel Queueable Action Laravel Queueable Action is a Spatie package that provides an elegant way to structure business logic in Laravel applications using the Action pattern. Actions are reusable classes that encapsulate specific business operations, and this package adds seamless support for making them queueable, allowing developers to execute actions either synchronously or asynchronously on Laravel's queue system with a simple fluent API. The package leverages Laravel's dependency injection container, enabling actions to receive constructor-injected dependencies while still being dispatchable to queues. It supports all standard Laravel queue features including queue selection, job chaining, batching, middleware, custom tags for Horizon, backoff strategies, and retry configurations. Actions can use either an `execute()` method or PHP's magic `__invoke()` method, providing flexibility in how business logic is organized. ## Installation Install the package via Composer. ```bash composer require spatie/laravel-queueable-action ``` ## Publishing Configuration Optionally publish the configuration file to customize the job class. ```bash php artisan vendor:publish --provider="Spatie\QueueableAction\QueueableActionServiceProvider" --tag="config" ``` ```php // config/queuableaction.php return [ /* * The job class that will be dispatched. * If you would like to change it and use your own job class, * it must extend the \Spatie\QueueableAction\ActionJob class. */ 'job_class' => \Spatie\QueueableAction\ActionJob::class, ]; ``` ## Generating Action Classes with Artisan Command The package provides an Artisan command to generate action classes. By default, it creates a queueable action; use the `--sync` flag for synchronous actions. ```bash # Create a queueable action class php artisan make:action SendWelcomeEmail # Create a synchronous action class php artisan make:action ProcessPayment --sync ``` ```php // Generated queueable action: app/Actions/SendWelcomeEmail.php emailService = $emailService; } // Parameters passed at execution time public function execute(User $user, string $subject = 'Welcome!') { $this->emailService->send($user->email, $subject, 'emails.welcome', [ 'name' => $user->name, ]); } } // Usage in a controller class UserController { public function store(Request $request, SendWelcomeEmail $action) { $user = User::create($request->validated()); // Execute on the queue (asynchronous) $action->onQueue()->execute($user, 'Welcome to our platform!'); // Or execute immediately (synchronous) $action->execute($user, 'Welcome to our platform!'); return response()->json(['user' => $user]); } } ``` ## Specifying Queue Name with onQueue() Dispatch actions to specific queues by passing the queue name to `onQueue()` or by setting a `$queue` property on the action class. ```php process(); $order->update(['status' => 'processed']); } } // Usage $action = app(ProcessOrder::class); // Uses the default 'orders' queue defined in the class $action->onQueue()->execute($order); // Override with a specific queue name $action->onQueue('high-priority')->execute($order); // Execute immediately without queuing $action->execute($order); ``` ## Using __invoke() Method Actions can use PHP's magic `__invoke()` method instead of `execute()`. The package automatically detects and uses the appropriate method. ```php inventory = $inventory; } public function __invoke(Product $product, int $quantity) { $this->inventory->adjust($product->id, $quantity); $product->update(['stock' => $product->stock + $quantity]); } } // Usage - still use execute() to dispatch, even with __invoke() $action = app(UpdateInventory::class); $action->onQueue()->execute($product, 50); ``` ## ActionJob Class for Chaining Actions Wrap actions in `ActionJob` to chain multiple actions together. The job accepts an action class or instance and an array of parameters. ```php onQueue() ->execute($order) ->chain([ // ActionJob accepts the action class and an array of parameters new ActionJob(SendOrderConfirmation::class, [$order]), new ActionJob(UpdateInventory::class, [$order->product, -$order->quantity]), new ActionJob(NotifyWarehouse::class, [$order]), ]); // Chain with shared arguments across actions $args = [$order->id, $order->customer_id]; app(ProcessOrder::class) ->onQueue() ->execute($order) ->chain([ new ActionJob(SendOrderConfirmation::class, $args), new ActionJob(UpdateInventory::class, $args), ]); ``` ## Batching Actions with Laravel Bus Use Laravel's Bus facade to dispatch multiple actions as a batch for parallel processing. ```php get(); // Dispatch actions as a batch Bus::batch( $images->map(fn ($image) => new ActionJob(ProcessImage::class, [$image])) )->then(function ($batch) { // All jobs completed successfully Log::info("Processed {$batch->totalJobs} images"); })->catch(function ($batch, $e) { // First batch job failure detected Log::error("Image processing failed: {$e->getMessage()}"); })->finally(function ($batch) { // Batch has finished executing Notification::send($admin, new BatchCompleted($batch)); })->dispatch(); // Batch multiple different actions together Bus::batch([ new ActionJob(ProcessImage::class, [$image1]), new ActionJob(GenerateThumbnail::class, [$image1, 'small']), new ActionJob(GenerateThumbnail::class, [$image1, 'medium']), ])->dispatch(); ``` ## Custom Tags for Horizon Override the `tags()` method to customize how actions appear in Laravel Horizon for monitoring and filtering. ```php user = $user; // Sync user data with external service } public function tags(): array { return [ 'sync', 'user-data', 'user:' . ($this->user?->id ?? 'unknown'), ]; } } // In Horizon, jobs will be tagged with: ['sync', 'user-data', 'user:123'] $action = app(SyncUserData::class); $action->onQueue()->execute($user); ``` ## Job Middleware Override the `middleware()` method to add Laravel job middleware for rate limiting, preventing overlaps, or other cross-cutting concerns. ```php notify(new GenericNotification($message)); } public function middleware(): array { return [ new RateLimited('notifications'), new WithoutOverlapping('user-notification'), ]; } } // Configure the rate limiter in AppServiceProvider use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('notifications', function ($job) { return Limit::perMinute(10); }); // Usage $action = app(SendNotification::class); $action->onQueue()->execute($user, 'Your order has shipped!'); ``` ## Backoff Strategy Configuration Configure retry delays using the `$backoff` property or `backoff()` method for exponential or custom backoff strategies. ```php charge($order); } } class ProcessPaymentWithExponentialBackoff { use QueueableAction; public function execute(Order $order) { app(ExternalPaymentGateway::class)->charge($order); } // Exponential backoff: 1s, 5s, then 10s between retries public function backoff(): array { return [1, 5, 10]; } } class ProcessPaymentWithDynamicBackoff { use QueueableAction; protected int $attemptCount = 0; public function execute(Order $order) { $this->attemptCount++; app(ExternalPaymentGateway::class)->charge($order); } // Dynamic backoff based on attempt count public function backoff(): int { return min($this->attemptCount * 2, 30); } } ``` ## Retry Until Configuration Specify a deadline after which the job should stop retrying using the `retryUntil()` method. ```php notify(new UrgentAlert($alertMessage)); } // Stop retrying after 30 minutes public function retryUntil(): DateTime { return now()->addMinutes(30); } } // The action will retry up to 10 times, but only within a 30-minute window $action = app(SendTimeSensitiveAlert::class); $action->onQueue('alerts')->execute($user, 'Server is down!'); ``` ## Handling Failed Jobs Define a `failed()` method on your action to handle exceptions when a job fails after all retry attempts. ```php order = $order; // Business logic that might fail $this->processPayment(); $this->reserveInventory(); $this->sendConfirmation(); } public function failed(Throwable $exception): void { // Log the failure Log::error('Order processing failed', [ 'order_id' => $this->order->id, 'error' => $exception->getMessage(), ]); // Update order status $this->order->update(['status' => 'failed']); // Notify administrators Notification::route('slack', config('services.slack.webhook')) ->notify(new OrderProcessingFailed($this->order, $exception)); } } ``` ## Queue Properties Configuration Configure standard Laravel queue properties directly on your action class. ```php records as $record) { $this->processRecord($record); } } } // All properties are automatically applied when queued $action = app(HeavyDataProcessing::class); $action->onQueue()->execute($dataset); ``` ## Custom ActionJob Class Extend `ActionJob` to create custom job classes with additional functionality. ```php actionClass), ]; } public function tags(): array { return array_merge(parent::tags(), ['custom-job']); } } // Configure in config/queuableaction.php return [ 'job_class' => \App\Jobs\CustomActionJob::class, ]; // Now all queued actions will use your custom job class $action = app(SendEmail::class); $action->onQueue()->execute($user); // Uses CustomActionJob ``` ## QueueableActionFake for Testing The package provides `QueueableActionFake` with PHPUnit assertions for testing queued actions. ```php create(); $action = app(SendWelcomeEmail::class); $action->onQueue()->execute($user); // Assert the action was pushed to the queue QueueableActionFake::assertPushed(SendWelcomeEmail::class); } public function test_action_queued_multiple_times(): void { Queue::fake(); $action = app(SendWelcomeEmail::class); $action->onQueue()->execute($user1); $action->onQueue()->execute($user2); $action->onQueue()->execute($user3); // Assert the action was pushed exactly 3 times QueueableActionFake::assertPushedTimes(SendWelcomeEmail::class, 3); } public function test_action_not_queued_on_validation_failure(): void { Queue::fake(); // Simulate validation failure scenario $this->postJson('/orders', ['invalid' => 'data']); // Assert the action was NOT pushed QueueableActionFake::assertNotPushed(ProcessOrder::class); } public function test_action_queued_with_chain(): void { Queue::fake(); $order = Order::factory()->create(); app(ProcessOrder::class) ->onQueue() ->execute($order) ->chain([ new ActionJob(NotifyWarehouse::class, [$order]), new ActionJob(SendWelcomeEmail::class, [$order->user]), ]); // Assert the action was pushed with the expected chain QueueableActionFake::assertPushedWithChain( ProcessOrder::class, [NotifyWarehouse::class, SendWelcomeEmail::class] ); } public function test_action_queued_without_chain(): void { Queue::fake(); app(ProcessOrder::class)->onQueue()->execute($order); // Assert the action was pushed without any chained jobs QueueableActionFake::assertPushedWithoutChain(ProcessOrder::class); } } ``` ## Complete Action Example with All Features A comprehensive example demonstrating all available features in a single action class. ```php paymentGateway = $paymentGateway; $this->inventory = $inventory; } public function execute(Order $order): void { $this->order = $order; // Process payment $this->paymentGateway->charge( $order->customer, $order->total_amount ); // Update inventory foreach ($order->items as $item) { $this->inventory->decrement($item->product_id, $item->quantity); } // Mark order as complete $order->update([ 'status' => 'completed', 'processed_at' => now(), ]); } public function middleware(): array { return [ new RateLimited('orders'), (new WithoutOverlapping($this->order?->id ?? 'default')) ->expireAfter(60), ]; } public function backoff(): array { return [5, 15, 30, 60, 120]; // Exponential backoff } public function retryUntil(): DateTime { return now()->addHours(2); } public function tags(): array { return [ 'order-processing', 'order:' . ($this->order?->id ?? 'unknown'), 'customer:' . ($this->order?->customer_id ?? 'unknown'), ]; } public function failed(Throwable $exception): void { if ($this->order) { $this->order->update(['status' => 'failed']); $this->order->customer->notify( new OrderFailed($this->order, $exception->getMessage()) ); } Log::error('Order processing failed', [ 'order_id' => $this->order?->id, 'exception' => $exception->getMessage(), 'trace' => $exception->getTraceAsString(), ]); } } // Usage in a controller class OrderController extends Controller { public function store( OrderRequest $request, ProcessOrderComplete $processOrder ) { $order = Order::create($request->validated()); // Queue the action with chaining $processOrder ->onQueue('high-priority') // Override default queue ->execute($order) ->chain([ new ActionJob(SendOrderConfirmation::class, [$order]), new ActionJob(NotifyWarehouse::class, [$order]), ]); return response()->json([ 'message' => 'Order received and processing', 'order' => $order, ], 202); } } ``` ## Summary Laravel Queueable Action provides a clean, maintainable pattern for organizing business logic in Laravel applications. By encapsulating operations in dedicated action classes with full support for Laravel's queue system, developers can build applications that are both testable and scalable. The package seamlessly integrates with Laravel's dependency injection, allowing actions to receive services through constructor injection while accepting runtime parameters through the `execute()` method. The main use cases include processing orders, sending notifications, syncing data with external services, handling file uploads, and any operation that benefits from being run asynchronously. Integration is straightforward: add the `QueueableAction` trait to your class, define your business logic in an `execute()` or `__invoke()` method, and use `onQueue()` to dispatch to Laravel's queue system. The package works with all Laravel queue drivers (Redis, SQS, database, etc.) and integrates seamlessly with Laravel Horizon for monitoring, making it an excellent choice for production applications requiring reliable background job processing.