Sometimes you have a controller method that started small and did not stay that way. You validate the request, create a record, then fired a couple of side effects, return a response, ect.
Every one of those steps is real work, and none of it belongs in the controller, but that is where it lands because the controller is where the request arrives.
Laractions is a small package for moving that work into single-purpose classes. This post is a straight refactor: one fat controller method pulled apart into an action you can call from anywhere.
How to install
Install via Composer. The service provider auto-registers and there is nothing to publish to get going:
composer require edulazaro/laractions
The controller we are cleaning up
Take this store method. It works, and it is the kind of method that grows a new responsibility every sprint.
public function store(Request $request)
{
$data = $request->validate([
'email' => 'required|email',
'plan' => 'required|string',
]);
$subscription = Subscription::create($data);
Mail::to($subscription->email)->send(new SubscriptionStarted($subscription));
activity()->log("subscription {$subscription->id} created");
return redirect()->route('subscriptions.index');
}
The controller's job is to turn a request into a response. Creating the subscription and firing the side effects is business logic, and three other places will eventually want to reuse it.
Extract it into an action
Generate an action. The generator drops the Action suffix, since App\Actions already says what the class is.
php artisan make:action CreateSubscription
namespace App\Actions;
use EduLazaro\Laractions\Action;
class CreateSubscription extends Action
{
public function handle(array $attributes): Subscription
{
$subscription = Subscription::create($attributes);
Mail::to($subscription->email)->send(new SubscriptionStarted($subscription));
activity()->log("subscription {$subscription->id} created");
return $subscription;
}
}
Now the controller shrinks back to its actual job.
public function store(Request $request)
{
$data = $request->validate([
'email' => 'required|email',
'plan' => 'required|string',
]);
CreateSubscription::create()->run($data);
return redirect()->route('subscriptions.index');
}
create() resolves the action through the container, so if it needed a mailer or a billing client you would type-hint it in the constructor and get it injected. run() calls handle(). Because handle() here takes a single array parameter, the validated $data array is passed through whole as that argument, which is why run($data) just works.
Passing arguments however you have them
That single-array call is one of three ways run() forwards to handle(). If handle() took discrete parameters instead, all of these would land the same way:
$action->run('a@b.com', 'pro'); // positional
$action->run(email: 'a@b.com', plan: 'pro'); // named
$action->run(['email' => 'a@b.com', 'plan' => 'pro']); // keyed by parameter name
The one rule to remember: a single array parameter receives the array whole, the attribute-bag call from the refactor above, while a concrete typed parameter such as handle(File $file) receives the value, not the array around it.
Bind an action to a model
When an action is about a specific record, generate it against the model and it gets a typed property for that record.
php artisan make:action CancelSubscription --model=Subscription
namespace App\Actions\Subscription;
class CancelSubscription extends Action
{
protected Subscription $subscription;
public function handle(string $reason): void
{
$this->subscription->update(['status' => 'cancelled', 'reason' => $reason]);
}
}
Add the HasActions trait to the model and register the actions it owns in an $actions array, keyed by a short name. That key is how you call them.
class Subscription extends Model
{
use HasActions;
protected array $actions = [
'cancel' => CancelSubscription::class,
];
}
Now you run the action straight off the model instance by its key, which is the form I use everywhere:
$subscription->action('cancel')->run('customer request');
The model injects itself into the action's $subscription property, so handle() reads $this->subscription directly, and run() executes it synchronously right there, with no queue involved. If you would rather not register a key, pass the class name instead and it resolves the same way:
$subscription->action(CancelSubscription::class)->run('customer request');
Let the action validate its own input
An action can carry its own rules in a $rules array, checked against the resolved arguments before handle() runs. On failure it throws the same ValidationException a form request throws, so the action guards itself even when it is called from a console command or a test that never went through HTTP validation.
class CreateSubscription extends Action
{
protected array $rules = [
'email' => 'required|email',
'plan' => 'required|string',
];
public function handle(array $attributes): Subscription
{
// ...
}
}
When you want it in the background
The same action can queue itself. Swap run() for dispatch() and it goes onto the queue as a job, with queue(), delay() and retry() to tune it.
CreateSubscription::create()
->queue('default')
->dispatch($data);
In practice I run everything synchronously with run() and reach for a dedicated queued Job when I want background work, but if a given action is a clean unit to defer, dispatch() is right there.
Wrapping up
If you liked this way of working aand want to collaborate with the development of Laractions, PRs are welcome 😊
👉 Package on Packagist: https://packagist.org/packages/edulazaro/laractions
👉 Source on GitHub: https://github.com/edulazaro/laractions
Top comments (0)