The same operation ends up in three places, and none of them agree

This is me

This is me

Here is how it goes. A create-invoice routine starts in a controller, because that is where the form posts. Then a nightly command needs to create invoices too, and copying forty lines is faster than untangling the controller. Then someone adds a variant on the model, because that felt like the natural place.

Six months later the operation exists in three places, they have drifted, and the one that gets fixed when a bug comes in is whichever one the reporter happened to hit.

The fix is not clever: give the operation a class and call that class from all three. The interesting part is what it takes to make that comfortable enough that you actually do it, rather than reaching for copy-paste again because the ceremony is not worth it for a six-line routine.

Container resolution, not static calls

The lightest possible API is a static method: SendWelcomeEmail::run($email). Two fewer characters and no instance to think about.

It also means the action can never have dependencies. The moment it needs a mailer, a client for some API, a repository, you are either instantiating them inside handle() (untestable, and now the action knows how to build its collaborators) or passing them at every call site (so every caller knows the action's internals).

So create() resolves through the container. An action type-hints what it needs in its constructor and gets it, exactly like a controller does:

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

create() takes the constructor arguments, run() takes the handle() arguments. Two calls instead of one, and in exchange the action is a normal, injectable, mockable object.

Reflection-based arguments, and the one rule

This is the decision that decided whether I would actually use the package.

Data arrives in different shapes depending on where you are. In a controller it is a validated array keyed by field name. In a command it is discrete arguments. In a test you want named arguments so the assertion reads clearly. If run() accepted only one of those, two out of three call sites would have to reshape data before calling, and reshaping-before-calling is exactly the friction that sends people back to copy-paste.

So run() maps to handle() by reflection and accepts all three:

$action->run('user@example.com', 'Welcome');
$action->run(email: 'user@example.com', subject: 'Welcome');
$action->run(['email' => 'user@example.com', 'subject' => 'Welcome']);

The array form is the one I use most, because $request->validated() already comes out shaped like that.

Any mapping this permissive has an ambiguity, and it is worth stating rather than hiding: what happens when handle() itself expects an array? Spreading it would be wrong; passing it whole would be wrong for a typed parameter. The rule is that a single array parameter receives the array whole, and a concrete typed parameter such as handle(File $file) receives the value. It matches how PHP itself treats fn(array $x) versus fn(File $x), so the surprising case behaves the way your intuition about PHP already expects.

Binding models by property, not by argument

Most actions belong to a record: send this user's email, close this case. Passing the model as an argument works, but then every model-scoped action starts with the same parameter and every call site repeats it.

The alternative was to have the model inject itself. HasActions on the model, a typed property on the action, and the binding walks the class and its parents to find a property whose type matches:

protected User $user;

That declaration is the entire wiring. handle() reads $this->user and takes no arguments at all, and the call site says what it means:

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

Registering actions in an $actions array gives you the short key; passing the class name works without registering anything, for the ones that do not earn a name.

The feature I shipped and never use

Actions can queue themselves. dispatch() instead of run(), with queue(), delay() and retry().

I want to be straight about this, because it is the kind of feature that goes in a README headline. Across three production apps with roughly a hundred actions between them, every single one runs synchronously. When I need background work I reach for a plain queued Job that calls the action.

That is not an accident, and having built both I think it is the right split. An action is a synchronous unit of business logic; whether a given invocation should happen now or later is a property of the caller, not of the operation. Baking the queue decision into the action pushes a scheduling concern down into the domain layer, where it then has to be configurable, because different callers want different answers.

The dispatch() path stays because the day I want it I will want it badly. But if you are choosing, the boring composition (Job calls Action) keeps the concerns where they belong.

What it bought

Three Laravel apps, business logic out of controllers and models, and one place to fix when the create-invoice rule changes.

Full API, the model binding, validation, actors and tracing are on the Laractions page. Source on GitHub, package on Packagist.