Asynchronous actions

There is no such thing as an asynchronous action class in Laractions. There is no trait to add, no interface to implement and no job to write beside it. Any action can be queued, and the only thing that changes is the verb at the end of the chain:

SendWelcomeEmail::create()->run('user@example.com', 'Welcome');       // inline, returns what handle() returns
SendWelcomeEmail::create()->dispatch('user@example.com', 'Welcome');  // queued, returns nothing

That symmetry is the point of the package. Whether an operation blocks the request or runs on a worker stops being a property of the code and becomes a decision at the call site. The controller that sends one welcome email during signup and the console command that backfills ten thousand of them call the same class, and the second one queues.

Earlier versions of the package did have an IsAsync trait, along with async-action stubs and a separate set of queue methods. It was removed in March 2025: the queue settings moved onto the base Action class and dispatching moved into Jobs\ActionJob. If you are upgrading from that era, delete the trait from your actions and they keep working. The timeout() method that lived on it no longer exists.

dispatch() takes the arguments in the same three shapes run() does, so nothing about the call has to be rewritten to queue it:

$action->dispatch('user@example.com', 'Welcome');                            // positional
$action->dispatch(['email' => 'user@example.com', 'subject' => 'Welcome']);  // keyed by name

Configuring the job

queue(), delay() and retry() configure the job at the call site and return the action, so they chain in any order before dispatch():

SendWelcomeEmail::create()
    ->queue('emails')   // queue name
    ->delay(10)         // seconds before it becomes available
    ->retry(5)          // attempts, not extra attempts
    ->dispatch('user@example.com', 'Welcome');

Or set the defaults on the action itself, so every caller inherits them and nobody has to remember:

class SendWelcomeEmail extends Action
{
    protected ?string $queue = 'emails';
    protected ?int $delay = 30;
    protected int $tries = 5;
}

The fluent methods write to those same properties, so a call that sets one overrides the class default for that call only. A class default with no fluent call is used as is.

Two things to watch, because neither is obvious:

  • retry(5) means five attempts in total, not five retries after the first. It becomes the job's tries, and Laravel counts the first run as one.
  • An action with no queue set goes to a queue literally named default, not to whatever queue your connection declares as its default. If your workers listen on a different queue name, either name it with queue() or add default to the worker's list, or the job sits there unprocessed.

What actually gets queued

dispatch() builds an EduLazaro\Laractions\Jobs\ActionJob, a normal ShouldQueue job, and dispatches it with the queue and delay you configured and with tries taken from the action. Understanding what that job carries explains most of the surprises:

  • The action instance itself travels inside the job. Everything the action holds in its properties is serialized with it, so a constructor dependency that cannot be serialized (an open connection, a stream, a closure) will fail at dispatch time. Resolve those inside handle() instead of injecting them.
  • The actionable model does not travel as an object. If the action was bound to an Eloquent model, the job stores its class and its primary key, and re-fetches it when it runs.
  • The arguments travel as data. They are handed back to run() on the worker, which resolves them against handle() exactly as it would inline.

Queued model actions

Model actions queue the same way, from the model:

$user->action('send_welcome')->dispatch();
$user->action(SendWelcomeEmail::class)->queue('emails')->dispatch();

Because the model is rebuilt on the worker from its id rather than being carried along, the action sees the record as it is when the job runs, not as it was when the job was queued. That is almost always what you want: an email that goes out ten minutes later should use the address the user has now.

The corollary is the failure case. If the record was deleted in between, the job fails with a ModelNotFoundException naming the model and the key it looked for, rather than running against a stale copy. When that is a normal outcome in your domain rather than an error, guard it: dispatch on a deleted_at aware query, or make the operation tolerant by taking the id as a handle() argument and looking the record up yourself.

The key is stored as it comes off the model, integer or string, so models with UUID or ULID primary keys queue like any other.

Validation happens on the worker

$rules are checked inside run(), and for a queued action run() happens on the worker. Dispatching does not validate anything: a bad payload is accepted at the call site and fails later as a failed job.

This trips people up in local development because a fresh .env ships QUEUE_CONNECTION=sync, which executes the job inline. With sync, a validation error surfaces as a ValidationException thrown right where you called dispatch(), and the delay you set appears to be ignored. Both are the connection, not the package. If you want the caller to fail fast on bad input, validate before dispatching, in the form request or with the same rules.

Failures, retries and logs

A failed job behaves like any other failed Laravel job: it is retried up to tries times and then recorded in failed_jobs if you have the table. On top of that, ActionJob::failed() writes the exception message and stack trace to the log, prefixed with the job class, so a failure is visible even without a failed jobs table.

enableLogging() adds a line at dispatch time with the queue, the delay, the number of tries and the parameters, which is the cheapest way to answer "was it even queued":

SendWelcomeEmail::create()
    ->enableLogging()
    ->queue('emails')
    ->dispatch('user@example.com', 'Welcome');

See Logging for what each of those writes and how to log from inside handle().

A worked example

The signup path stays synchronous where it must be and defers the rest:

public function store(StoreUserRequest $request)
{
    $user = CreateUser::create()->run($request->validated());

    $user->action(SendWelcomeEmail::class)
        ->queue('emails')
        ->retry(3)
        ->dispatch();

    $user->action(SyncToCrm::class)
        ->delay(60)
        ->dispatch();

    return redirect()->route('dashboard');
}

CreateUser runs inline because the response depends on its return value. The other two do not, so they queue, and the request returns as soon as the user row exists.

php artisan queue:work --queue=emails,default

Asserting it in tests

Queue::fake() gives you the job, and the job gives you the action, so a test can assert both that something was queued and how:

Queue::fake();

$user->action(SendWelcomeEmail::class)->queue('emails')->retry(3)->dispatch();

Queue::assertPushed(ActionJob::class, function ($job) {
    return $job->action instanceof SendWelcomeEmail
        && $job->queue === 'emails'
        && $job->tries === 3;
});

More patterns, including running the queued action inside the assertion, are in Testing.