Model actions

Most operations are about a record: this user, this invoice, this listing. A model action takes the record as a property rather than as an argument, so handle() only receives what is specific to the call.

Generate one

php artisan make:action SendWelcomeEmail --model=User

The class lands in App\Actions\User, namespaced by the model, with the property already declared:

namespace App\Actions\User;

use EduLazaro\Laractions\Action;
use App\Models\User;

class SendWelcomeEmail extends Action
{
    protected User $user;

    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeMail());
    }
}

--model accepts a fully qualified name too, so --model="App\Models\Billing\Invoice" gives you App\Actions\Invoice.

Let the model run it

Add HasActions to the model and it gains an action() method:

use EduLazaro\Laractions\Concerns\HasActions;

class User extends Model
{
    use HasActions;
}

$user->action(SendWelcomeEmail::class)->run();

The action is resolved through the container and bound to the model in one step, so constructor dependencies still get injected and $this->user is already set when handle() runs.

Registering short keys

Passing the class means an use statement wherever the action is called. Registering it in an $actions array on the model gives you a short name instead, and a single list of what the model can do:

class User extends Model
{
    use HasActions;

    protected array $actions = [
        'send_welcome' => SendWelcomeEmail::class,
        'anonymize'    => AnonymizeUser::class,
    ];
}

$user->action('send_welcome')->run();

Both forms coexist: action() first checks whether what you passed is an existing class, and only falls back to the $actions map if it is not. An unregistered key that is not a class throws Action x is not defined in the model's actions array, which is the error to expect after a typo or a rename.

The map is also the honest place to document a model's behaviour. A User with ten registered actions tells you more about the domain than a User with ten methods, because each entry is a class you can open, queue and test on its own.

How the model reaches the action

Binding happens in on(), which action() calls for you, and the rule is worth knowing because it explains why no configuration is needed. on() takes the class basename of the object, lowercases the first letter, and assigns the object to the property of that name if the action declares one. It then walks the parent classes and does the same, taking the first match.

The parent walk is what makes inheritance work. If Admin extends User and the action declares protected User $user, then $admin->action(SendWelcomeEmail::class) still fills $user, because Admin did not match but its parent did.

Nothing breaks when the action declares no matching property: the object is still stored as the actionable and is available through getActionable(). That is the case for an action that acts on a record without needing to read it.

Calling on() yourself

on() is public, so an action can be bound outside a model, which is handy when the action was created through create() for other reasons:

SendWelcomeEmail::create()->on($user)->run();

It also accepts any object, not only Eloquent models. A value object or a DTO is bound to a matching property exactly the same way. The Eloquent part only becomes relevant when the action is queued, because that is when the actionable has to be stored and rebuilt, and only a model has an id to store. See Asynchronous actions.

Constructor parameters from the model

action() takes a second argument, an array passed to the container as constructor parameters. Use it when a dependency cannot be resolved on its own:

$user->action(SendWelcomeEmail::class, ['locale' => 'ca'])->run();

These are constructor parameters, not handle() arguments. What handle() receives still comes from run() or dispatch().

Swapping an action in tests

mockAction() replaces an action class on a single model instance, so a test can assert around an operation without executing it:

$user->mockAction(SendWelcomeEmail::class, new class {
    public function run() { return 'mocked'; }
});

$user->action(SendWelcomeEmail::class)->run();  // 'mocked'

The substitution is only honoured while the application is running tests, so a stray mockAction() call cannot change behaviour in production. It is deprecated in favour of binding the action in the container, which also replaces it when it is created with create() or act(), or from inside another action. See Testing.