├── LICENSE.md ├── README.md ├── composer.json ├── config └── queuableaction.php └── src ├── ActionJob.php ├── ActionMakeCommand.php ├── Exceptions └── InvalidConfiguration.php ├── QueueableAction.php ├── QueueableActionServiceProvider.php ├── Testing └── QueueableActionFake.php └── stubs ├── action-queued.stub └── action.stub /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Queueable actions in Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-queueable-action.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-queueable-action) 4 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-queueable-action/run-tests.yml?label=tests) 5 | ![Check & fix styling](https://github.com/spatie/laravel-queueable-action/workflows/Check%20&%20fix%20styling/badge.svg) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-queueable-action.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-queueable-action) 7 | 8 | Actions are a way of structuring your business logic in Laravel. 9 | This package adds easy support to make them queueable. 10 | 11 | ```php 12 | $myAction->onQueue()->execute(); 13 | ``` 14 | 15 | You can specify a queue name. 16 | 17 | ```php 18 | $myAction->onQueue('my-favorite-queue')->execute(); 19 | ``` 20 | 21 | ## Support us 22 | 23 | [](https://spatie.be/github-ad-click/laravel-queueable-action) 24 | 25 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 26 | 27 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 28 | 29 | ## Installation 30 | 31 | You can install the package via composer: 32 | 33 | ```bash 34 | composer require spatie/laravel-queueable-action 35 | ``` 36 | 37 | You can optionally publish the config file with: 38 | 39 | ```bash 40 | php artisan vendor:publish --provider="Spatie\QueueableAction\QueueableActionServiceProvider" --tag="config" 41 | ``` 42 | 43 | This is the contents of the published config file: 44 | 45 | ```php 46 | return [ 47 | /* 48 | * The job class that will be dispatched. 49 | * If you would like to change it and use your own job class, 50 | * it must extends the \Spatie\QueueableAction\ActionJob class. 51 | */ 52 | 'job_class' => \Spatie\QueueableAction\ActionJob::class, 53 | ]; 54 | ``` 55 | 56 | ## Usage 57 | 58 | If you want to know about the reasoning behind actions and their asynchronous usage, 59 | you should read the dedicated blog post: [https://stitcher.io/blog/laravel-queueable-actions](https://stitcher.io/blog/laravel-queueable-actions). 60 | 61 | You can use the following Artisan command to generate queueable and synchronous action classes on the fly. 62 | 63 | ``` 64 | php artisan make:action MyAction [--sync] 65 | ``` 66 | 67 | Here's an example of queueable actions in use: 68 | 69 | ``` php 70 | class MyAction 71 | { 72 | use QueueableAction; 73 | 74 | public function __construct( 75 | OtherAction $otherAction, 76 | ServiceFromTheContainer $service 77 | ) { 78 | // Constructor arguments can come from the container. 79 | 80 | $this->otherAction = $otherAction; 81 | $this->service = $service; 82 | } 83 | 84 | public function execute( 85 | MyModel $model, 86 | RequestData $requestData 87 | ) { 88 | // The business logic goes here, this can be executed in an async job. 89 | } 90 | } 91 | ``` 92 | 93 | ```php 94 | class MyController 95 | { 96 | public function store( 97 | MyRequest $request, 98 | MyModel $model, 99 | MyAction $action 100 | ) { 101 | $requestData = RequestData::fromRequest($myRequest); 102 | 103 | // Execute the action on the queue: 104 | $action->onQueue()->execute($model, $requestData); 105 | 106 | // Or right now: 107 | $action->execute($model, $requestData); 108 | } 109 | } 110 | ``` 111 | 112 | The package also supports actions using the `__invoke()` method. This will be detected automatically. Here is an example: 113 | 114 | ``` php 115 | class MyInvokeableAction 116 | { 117 | use QueueableAction; 118 | 119 | public function __invoke( 120 | MyModel $model, 121 | RequestData $requestData 122 | ) { 123 | // The business logic goes here, this can be executed in an async job. 124 | } 125 | } 126 | ``` 127 | 128 | The actions using the `__invoke()` method should be added to the queue the same way as explained in the examples above, by running the `execute()` method after the `onQueue()` method. 129 | 130 | ```php 131 | $myInvokeableAction->onQueue()->execute($model, $requestData); 132 | ``` 133 | 134 | ### Testing queued actions 135 | 136 | The package provides some test assertions in the `Spatie\QueueableAction\Testing\QueueableActionFake` class. You can use them in a PhpUnit test like this: 137 | 138 | ```php 139 | 140 | /** @test */ 141 | public function it_queues_an_action() 142 | { 143 | Queue::fake(); 144 | 145 | (new DoSomethingAction)->onQueue()->execute(); 146 | 147 | QueueableActionFake::assertPushed(DoSomethingAction::class); 148 | } 149 | ``` 150 | 151 | Don't forget to use `Queue::fake()` to mock Laravel's queues before using the `QueueableActionFake` assertions. 152 | 153 | The following assertions are available: 154 | 155 | ```php 156 | QueueableActionFake::assertPushed(string $actionClass); 157 | QueueableActionFake::assertPushedTimes(string $actionClass, int $times = 1); 158 | QueueableActionFake::assertNotPushed(string $actionClass); 159 | QueueableActionFake::assertPushedWithChain(string $actionClass, array $expextedActionChain = []) 160 | QueueableActionFake::assertPushedWithoutChain(string $actionClass) 161 | ``` 162 | 163 | Feel free to send a PR if you feel any of the other `QueueFake` assertions are missing. 164 | 165 | ### Chaining actions 166 | 167 | You can chain actions by wrapping them in the `ActionJob`. 168 | 169 | Here's an example of two actions with the same arguments: 170 | 171 | ```php 172 | use Spatie\QueueableAction\ActionJob; 173 | 174 | $args = [$userId, $data]; 175 | 176 | app(MyAction::class) 177 | ->onQueue() 178 | ->execute(...$args) 179 | ->chain([ 180 | new ActionJob(AnotherAction::class, $args), 181 | ]); 182 | ``` 183 | 184 | The `ActionJob` takes the action class *or* instance as the first argument followed by an array of the action's own arguments. 185 | 186 | ### Custom Tags 187 | 188 | If you want to change what tags show up in Horizon for your custom actions you can override the `tags()` function. 189 | 190 | ``` php 191 | class CustomTagsAction 192 | { 193 | use QueueableAction; 194 | 195 | // ... 196 | 197 | public function tags() { 198 | return ['action', 'custom_tags']; 199 | } 200 | } 201 | ``` 202 | 203 | ### Job Middleware 204 | 205 | Middleware where action job passes through can be added by overriding the `middleware()` function. 206 | 207 | ``` php 208 | class CustomTagsAction 209 | { 210 | use QueueableAction; 211 | 212 | // ... 213 | 214 | public function middleware() { 215 | return [new RateLimited()]; 216 | } 217 | } 218 | ``` 219 | 220 | ### Action Backoff 221 | 222 | If you would like to configure how many seconds Laravel should wait before retrying an action that has encountered 223 | an exception on a per-action basis, you may do so by defining a backoff property on your action class: 224 | 225 | ``` php 226 | class BackoffAction 227 | { 228 | use QueueableAction; 229 | 230 | /** 231 | * The number of seconds to wait before retrying the action. 232 | * 233 | * @var array|int 234 | */ 235 | public $backoff = 3; 236 | } 237 | ``` 238 | 239 | If you require more complex logic for determining the action's backoff time, you may define a backoff method on your action class: 240 | 241 | ``` php 242 | class BackoffAction 243 | { 244 | use QueueableAction; 245 | 246 | /** 247 | * Calculate the number of seconds to wait before retrying the action. 248 | * 249 | */ 250 | public function backoff(): int 251 | { 252 | return 3; 253 | } 254 | } 255 | ``` 256 | 257 | You may easily configure "exponential" backoffs by returning an array of backoff values from the backoff method. 258 | In this example, the retry delay will be 1 second for the first retry, 5 seconds for the second retry, and 10 seconds for the third retry: 259 | 260 | ``` php 261 | class BackoffAction 262 | { 263 | /** 264 | * Calculate the number of seconds to wait before retrying the action. 265 | * 266 | */ 267 | public function backoff(): array 268 | { 269 | return [1, 5, 10]; 270 | } 271 | } 272 | ``` 273 | 274 | ### What is the difference between actions and jobs? 275 | 276 | In short: constructor injection allows for much more flexibility. 277 | You can read an in-depth explanation here: [https://stitcher.io/blog/laravel-queueable-actions](https://stitcher.io/blog/laravel-queueable-actions#what's-the-difference-with-jobs?!?). 278 | 279 | ### Testing the package 280 | 281 | ``` bash 282 | composer test 283 | ``` 284 | 285 | ### Changelog 286 | 287 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 288 | 289 | ## Contributing 290 | 291 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 292 | 293 | ### Security 294 | 295 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 296 | 297 | ## Credits 298 | 299 | - [Brent Roose](https://github.com/brendt) 300 | - [Alex Vanderbist](https://github.com/alexvanderbist) 301 | - [Sebastian De Deyne](https://github.com/sebdedeyne) 302 | - [Freek Van der Herten](https://github.com/freekmurze) 303 | - [All Contributors](../../contributors) 304 | 305 | ## License 306 | 307 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 308 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-queueable-action", 3 | "description": "Queueable action support in Laravel", 4 | "keywords": [ 5 | "spatie", 6 | "laravel-queueable-action" 7 | ], 8 | "homepage": "https://github.com/spatie/laravel-queueable-action", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Brent Roose", 13 | "email": "brent@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "Alex Vanderbist", 19 | "email": "alex@spatie.be", 20 | "homepage": "https://spatie.be", 21 | "role": "Developer" 22 | }, 23 | { 24 | "name": "Sebastian De Deyne", 25 | "email": "sebastian@spatie.be", 26 | "homepage": "https://spatie.be", 27 | "role": "Developer" 28 | }, 29 | { 30 | "name": "Freek Van der Herten", 31 | "email": "freek@spatie.be", 32 | "homepage": "https://spatie.be", 33 | "role": "Developer" 34 | } 35 | ], 36 | "require": { 37 | "php": "^8.0", 38 | "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0" 39 | }, 40 | "require-dev": { 41 | "mockery/mockery": "^1.4", 42 | "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0", 43 | "pestphp/pest": "^1.22|^2.34|^3.7", 44 | "pestphp/pest-plugin-laravel": "^1.3|^2.3|^3.1" 45 | }, 46 | "autoload": { 47 | "psr-4": { 48 | "Spatie\\QueueableAction\\": "src" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Spatie\\QueueableAction\\Tests\\": "tests" 54 | } 55 | }, 56 | "scripts": { 57 | "test": "vendor/bin/pest", 58 | "test-coverage": "vendor/bin/pest --coverage-html coverage" 59 | }, 60 | "config": { 61 | "sort-packages": true, 62 | "allow-plugins": { 63 | "pestphp/pest-plugin": true 64 | } 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "providers": [ 69 | "Spatie\\QueueableAction\\QueueableActionServiceProvider" 70 | ] 71 | } 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /config/queuableaction.php: -------------------------------------------------------------------------------- 1 | \Spatie\QueueableAction\ActionJob::class, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/ActionJob.php: -------------------------------------------------------------------------------- 1 | actionClass = is_string($action) ? $action : get_class($action); 43 | $this->parameters = $parameters; 44 | 45 | if (is_object($action)) { 46 | $this->tags = $action->tags(); 47 | $this->middleware = $action->middleware(); 48 | 49 | if (method_exists($action, 'backoff')) { 50 | $this->backoff = $action->backoff(); 51 | } 52 | 53 | if (method_exists($action, 'retryUntil')) { 54 | $this->retryUntil = $action->retryUntil(); 55 | } 56 | 57 | if (method_exists($action, 'failed')) { 58 | $this->onFailCallback = [$action, 'failed']; 59 | } 60 | } 61 | 62 | $this->resolveQueueableProperties($this->actionClass); 63 | } 64 | 65 | public function displayName(): string 66 | { 67 | return $this->actionClass; 68 | } 69 | 70 | public function tags() 71 | { 72 | return $this->tags; 73 | } 74 | 75 | public function middleware() 76 | { 77 | return []; 78 | } 79 | 80 | public function parameters() 81 | { 82 | return $this->parameters; 83 | } 84 | 85 | public function backoff() 86 | { 87 | return $this->backoff; 88 | } 89 | 90 | public function retryUntil() 91 | { 92 | return $this->retryUntil; 93 | } 94 | 95 | public function failed(Throwable $exception) 96 | { 97 | if ($this->onFailCallback) { 98 | return ($this->onFailCallback)($exception); 99 | } 100 | } 101 | 102 | public function handle() 103 | { 104 | $action = app($this->actionClass); 105 | $action->job = $this->job; 106 | $action->{$action->queueMethod()}(...$this->parameters); 107 | } 108 | 109 | public function __serialize() 110 | { 111 | foreach ($this->parameters as $index => $parameter) { 112 | $this->parameters[$index] = $this->getSerializedPropertyValue($parameter); 113 | } 114 | 115 | return $this->serializesModelsSerialize(); 116 | } 117 | 118 | public function __unserialize(array $values) 119 | { 120 | $this->serializesModelsUnserialize($values); 121 | 122 | foreach ($this->parameters as $index => $parameter) { 123 | $this->parameters[$index] = $this->getRestoredPropertyValue($parameter); 124 | } 125 | 126 | return $values; 127 | } 128 | 129 | protected function resolveQueueableProperties($action) 130 | { 131 | $queueableProperties = [ 132 | 'connection', 133 | 'queue', 134 | 'chainConnection', 135 | 'chainQueue', 136 | 'delay', 137 | 'chained', 138 | 'tries', 139 | 'timeout', 140 | 'maxExceptions', 141 | 'retryUntil', 142 | ]; 143 | 144 | foreach ($queueableProperties as $queueableProperty) { 145 | if (property_exists($action, $queueableProperty)) { 146 | $this->{$queueableProperty} = app($action)->{$queueableProperty}; 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/ActionMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('sync') 19 | ? __DIR__.'/stubs/action.stub' 20 | : __DIR__.'/stubs/action-queued.stub'; 21 | } 22 | 23 | protected function getDefaultNamespace($rootNamespace): string 24 | { 25 | return $rootNamespace.'\Actions'; 26 | } 27 | 28 | protected function getOptions(): array 29 | { 30 | return [ 31 | ['sync', null, InputOption::VALUE_NONE, 'Indicates that action should be synchronous'], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidConfiguration.php: -------------------------------------------------------------------------------- 1 | action = $action; 26 | $this->onQueue($queue); 27 | } 28 | 29 | public function execute(...$parameters) 30 | { 31 | $actionJobClass = $this->determineActionJobClass(); 32 | 33 | return dispatch(new $actionJobClass($this->action, $parameters)) 34 | ->onQueue($this->queue); 35 | } 36 | 37 | protected function onQueue(?string $queue): void 38 | { 39 | if (is_string($queue)) { 40 | $this->queue = $queue; 41 | 42 | return; 43 | } 44 | 45 | if (isset($this->action->queue)) { 46 | $this->queue = $this->action->queue; 47 | } 48 | } 49 | 50 | protected function determineActionJobClass(): string 51 | { 52 | $actionJobClass = config('queuableaction.job_class') ?? ActionJob::class; 53 | 54 | if (! is_a($actionJobClass, ActionJob::class, true)) { 55 | throw InvalidConfiguration::jobClassIsNotValid($actionJobClass); 56 | } 57 | 58 | return $actionJobClass; 59 | } 60 | }; 61 | 62 | return $class; 63 | } 64 | 65 | public function middleware(): array 66 | { 67 | return []; 68 | } 69 | 70 | public function queueMethod(): string 71 | { 72 | if (method_exists($this, '__invoke')) { 73 | return '__invoke'; 74 | } 75 | 76 | return 'execute'; 77 | } 78 | 79 | /** 80 | * @return array|int 81 | */ 82 | public function backoff() 83 | { 84 | return $this->backoff ?? []; 85 | } 86 | 87 | /** 88 | * @return string[] 89 | */ 90 | public function tags(): array 91 | { 92 | return [self::class]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/QueueableActionServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 13 | __DIR__.'/../config/queuableaction.php' => config_path('queuableaction.php'), 14 | ], 'config'); 15 | 16 | $this->mergeConfigFrom(__DIR__.'/../config/queuableaction.php', 'queuableaction'); 17 | } 18 | 19 | public function register(): void 20 | { 21 | if ($this->app->runningInConsole()) { 22 | $this->commands([ 23 | ActionMakeCommand::class, 24 | ]); 25 | } 26 | } 27 | 28 | public function provides(): array 29 | { 30 | return [ 31 | ActionMakeCommand::class, 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Testing/QueueableActionFake.php: -------------------------------------------------------------------------------- 1 | all() === $expectedActionChain, 49 | 'The expected chain was not pushed.' 50 | ); 51 | } 52 | 53 | public static function assertPushedWithoutChain(string $actionJobClass) 54 | { 55 | static::assertQueueIsFake(); 56 | 57 | $pushed = static::actionJobWasPushed($actionJobClass); 58 | 59 | Assert::assertTrue($pushed, "`{$actionJobClass}` was not pushed."); 60 | 61 | $matching = static::getChainedClasses(); 62 | 63 | Assert::assertTrue( 64 | $matching->isEmpty(), 65 | 'The expected chain was not empty.' 66 | ); 67 | } 68 | 69 | protected static function actionJobWasPushed(string $actionJobClass): bool 70 | { 71 | return static::getPushedCount($actionJobClass) > 0; 72 | } 73 | 74 | protected static function getPushedCount(string $actionJobClass): int 75 | { 76 | return collect(Queue::pushedJobs()[self::determineActionJobClass()] ?? []) 77 | ->map(function (array $queuedJob) { 78 | return $queuedJob['job']->displayName(); 79 | }) 80 | ->filter(function (string $displayName) use ($actionJobClass) { 81 | return $displayName === $actionJobClass; 82 | }) 83 | ->count(); 84 | } 85 | 86 | protected static function assertQueueIsFake() 87 | { 88 | Assert::assertTrue(Queue::getFacadeRoot() instanceof QueueFake, 'Queue was not faked. Use `Queue::fake()`.'); 89 | } 90 | 91 | protected static function getChainedClasses() 92 | { 93 | return collect(Queue::pushedJobs()[self::determineActionJobClass()] ?? []) 94 | ->map(fn ($actionJob) => $actionJob['job']->chained) 95 | ->map(function ($chain) { 96 | return collect($chain)->map(function ($job) { 97 | return unserialize($job)->displayName(); 98 | }); 99 | }) 100 | ->flatten(); 101 | } 102 | 103 | protected static function determineActionJobClass(): string 104 | { 105 | return config('queuableaction.job_class') ?? ActionJob::class; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/stubs/action-queued.stub: -------------------------------------------------------------------------------- 1 |