How to move a Laravel action onto a queue without rewriting it
The listing form got slow. Not mysteriously slow: it pushes to three property portals over HTTP, and one of them takes four seconds on a good day, so the user sits there watching a spinner because someone else's API is having an afternoon.
The standard fix is to write a job. Which means a second class, the operation's code moved into it, and from then on two files that have to be kept in step. Anything you fix in one, you remember to fix in the other, until the day you do not.
If the operation is already an action, there is nothing to move.
$property->action('push_to_portals')->run(); // during the request $property->action('push_to_portals')->dispatch(); // on a worker
That is the whole change. Same class, same handle(), same arguments. No job to write, no code copied anywhere.
Configure the job at the call site
queue(), delay() and retry() return the action, so they chain before dispatch():
$property->action('push_to_portals') ->queue('portals') ->delay(5) ->retry(3) ->dispatch();
Or set the defaults on the action, so every caller gets them without having to remember:
class PushToPortals extends Action { protected ?string $queue = 'portals'; protected ?int $delay = null; protected int $tries = 3; }
The fluent calls write to those same properties, so a call that sets one overrides the default for that call only.
Two details that bite:
retry(3)is three attempts in total, not three retries after the first. It becomes the job'stries, and Laravel counts the first run as one of them.- An action with no queue set goes to a queue named
default, not to whatever your connection declares as its default. If your workers listen elsewhere, name it, or adddefaultto the worker.
What actually gets queued
Worth knowing, because it explains the behaviour you are about to see.
The action instance travels inside the job. Everything it holds in its properties is serialized with it, which is the reason to resolve heavy or connection-bound collaborators inside handle() with app() rather than injecting them into the constructor: a closure or an open handle cannot be serialized, and it fails at dispatch.
The model does not travel as an object. When the action is bound to an Eloquent model, the job stores its class and its key, and looks it up again when it runs. Two consequences, and both are usually what you want:
- The action sees the record as it is when the job runs, not as it was when you queued it. A portal push five minutes later sends the price the listing has now.
- If the record was deleted in between, the job fails with a
ModelNotFoundExceptionnaming the model and the key, instead of running against a stale copy.
If a deletion is a normal outcome in your domain rather than an error, do not let the binding do the lookup: take the id as a handle() argument and decide for yourself what a missing record means.
Validation moves to the worker
$rules are checked inside run(), and for a queued action run() happens on the worker. Dispatching validates nothing: a bad payload is accepted at the call site and fails later, as a failed job.
This surprises people locally because a fresh .env ships QUEUE_CONNECTION=sync, which runs the job inline. With sync a validation error is thrown right where you called dispatch(), and the delay you set looks like it is being ignored. Both are the connection, not the package. If the caller needs to fail fast on bad input, validate before dispatching, where the user can still see it.
The action that returned something
run() gives you back whatever handle() returns. dispatch() returns nothing, because there is nobody to return it to yet.
So an operation whose result the response needs cannot simply be queued, and the fix is not to queue it and hope. Split it along that line:
public function store(StorePropertyRequest $request) { // The response needs the model, so this stays inline. $property = CreateProperty::create()->run($request->validated()); // Nothing on this page depends on these, so they go to a worker. $property->action('notify_matches')->queue('mail')->dispatch(); $property->action('push_to_portals')->queue('portals')->retry(3)->dispatch(); return redirect()->route('properties.show', $property); }
The question is never "is this slow", it is "does the response depend on its result". Creating the listing does, so it stays. The portal push does not, so it goes.
Test that it was queued, and what it does
Queue::fake() stops the job and hands it to you, and because the action rides inside the job you can assert on the configuration too:
The class to assert on is EduLazaro\Laractions\Jobs\ActionJob, the wrapper dispatch() builds:
public function test_publishing_queues_the_portal_push(): void { Queue::fake(); $property->action('push_to_portals')->queue('portals')->retry(3)->dispatch(); Queue::assertPushed(ActionJob::class, function ($job) { return $job->action instanceof PushToPortals && $job->queue === 'portals' && $job->tries === 3; }); }
To assert what it does rather than that it was queued, run the job inside the closure. That executes the action exactly as a worker would, model lookup and validation included:
Queue::assertPushed(ActionJob::class, function ($job) { $job->handle(); return true; });
One trap: with sync and no Queue::fake(), a dispatch runs inline, so a test that forgets the fake passes for the wrong reason. Asserting on ActionJob is what tells the two apart.
When not to queue
The feature makes it a one word decision, which makes it easy to take badly.
- If the user needs the result, it stays inline. A queued action cannot answer a question.
- If it is fast and it never fails, leave it. A worker is a moving part, and a queue that nobody is monitoring is a silent hole.
- If the order matters, say so. Two actions dispatched together are two independent jobs and they will not run in the order you wrote them.
- If it must happen before the next thing the user does, a delay is a bug, not a setting.
What is left after those is exactly the shape that belongs on a worker: slow, nobody waiting, and safe to retry.
Start the worker on the queue you named and you are done:
php artisan queue:work --queue=portals,mail,default
This post carries on from refactoring a fat controller into actions. The full behaviour of the queued side, including what the job carries and how failures are logged, is in the asynchronous actions chapter.
written by Edu Lazaro · August 2026