Creating actions

An action is a class extending EduLazaro\Laractions\Action with a handle() method. Everything else in the package exists to call that method well: from a controller, from a queue worker, on behalf of a user, with its arguments checked.

Generate one

php artisan make:action SendWelcomeEmail
namespace App\Actions;

use EduLazaro\Laractions\Action;

class SendWelcomeEmail extends Action
{
    public function handle()
    {
        // Your action logic here
    }
}

Name it without an Action suffix. The App\Actions namespace already says what the class is, the same way Laravel writes App\Jobs\SendEmail rather than App\Jobs\SendEmailJob. The generator uses the name exactly as you type it, so make:action SendWelcomeEmailAction really does produce a class called SendWelcomeEmailAction: the convention is yours to keep, not something the command enforces.

Write the work into handle()

Declare on handle() exactly the arguments the operation needs. They are its signature, its documentation and, together with $rules, its contract.

class SendWelcomeEmail extends Action
{
    public function handle(string $email, string $subject, string $message): void
    {
        Mail::to($email)->send(new WelcomeMail($subject, $message));
    }
}

Two details the signature is free to use:

  • handle() may be protected. It is only ever called from inside the action, so keeping it protected stops anyone bypassing validation by calling it directly. run() stays the single entry point.
  • Whatever handle() returns, run() returns. An action is not obliged to be a void side effect: returning the model it created or the total it computed is usually what makes it worth calling from more than one place.

Run it

$result = SendWelcomeEmail::create()->run('user@example.com', 'Welcome!', 'Hello there');

You never new an action. create() resolves it through Laravel's container, which is what makes the next section possible.

Constructor injection

The constructor is for collaborators, handle() is for the data of this particular call. Because create() goes through the container, anything you type hint on the constructor is injected:

namespace App\Actions;

use EduLazaro\Laractions\Action;
use App\Services\MailerService;

class SendWelcomeEmail extends Action
{
    public function __construct(protected MailerService $mailer) {}

    public function handle(string $email, string $subject, string $message): void
    {
        $this->mailer->send($email, $subject, $message);
    }
}

The call site does not change:

SendWelcomeEmail::create()->run('user@example.com', 'Welcome!', 'Hello there');

Arguments given to create() are passed to the container as constructor parameters, so use names when you need to override one that cannot be resolved on its own:

SendWelcomeEmail::create(mailer: $fakeMailer);

Keep in mind that a dispatched action travels with its job, so constructor dependencies have to survive serialization. If a collaborator holds a connection, a stream or a closure, resolve it inside handle() with app() instead of injecting it. See Asynchronous actions for what exactly goes into the payload.

Actions are callable

Action implements __invoke(), which forwards to run(). An action instance can therefore be handed to anything that expects a callable, which is convenient with collections and pipelines:

$action = ImportRow::create();

collect($rows)->each($action);          // one call per row
$total = collect($rows)->map($action);  // and their return values

How big should an action be?

The useful size is one decision. PublishPost is an action; HandleEverythingAboutPosts is a service with a new name. When two actions share a step, extract the step into a third action and call it from both: an action calling OtherAction::create()->run(...) is normal, and it is how a queued action ends up composing work that each piece can also do alone.