Move business logic out of your Laravel controllers with Laractions
DEV Community

Move business logic out of your Laravel controllers with Laractions

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)

Comments

No comments yet. Start the discussion.