Fat controllers happen gradually: a store method starts with validation and a create call, then six months later it's sending notifications, updating three related models, dispatching a job, and logging an audit entry, all in one method that nothing else in the app can reuse. The Actions pattern — popularised by packages like lorisleiva/laravel-actions and spatie's own take on the idea — is a way out of that: one class, one job, callable from a controller, a queued job, an Artisan command, or an event listener without duplicating the logic in each place.
The core idea: one invokable class per business action
Instead of a service class with a dozen loosely related methods, or logic embedded directly in a controller, an Action is a class with a single public entry point — often handle() or __invoke() — that does exactly one thing: CreateOrder, CancelSubscription, SendWelcomeEmail. The class name describes the business operation, not a CRUD verb on a model, which makes the codebase's intent readable from the directory listing alone: app/Actions/Orders/CreateOrder.php tells you more than OrderService::create() buried among nine other methods.
class CreateOrder
{
public function __construct(
private readonly InventoryService $inventory,
private readonly NotifiesCustomer $notifier,
) {}
public function handle(Customer $customer, array $items): Order
{
return DB::transaction(function () use ($customer, $items) {
$order = $customer->orders()->create([
'status' => OrderStatus::Pending,
'total' => $this->calculateTotal($items),
]);
foreach ($items as $item) {
$order->lines()->create($item);
$this->inventory->reserve($item['sku'], $item['qty']);
}
$this->notifier->orderCreated($order);
return $order;
});
}
}
Calling the same class from a controller, a job, or a command
The payoff shows up when the same business logic needs to run from more than one entry point. A plain class with a handle() method can be instantiated directly in a controller, resolved from the container inside a queued job, or invoked from an Artisan command — no duplication, because the logic lives in exactly one place. Packages like lorisleiva/laravel-actions add optional traits (AsController, AsJob, AsListener, AsCommand) that let the same class register itself as a route handler or a queued job with almost no boilerplate, but you get most of the benefit even hand-rolling it with plain PHP classes and Laravel's container.
// From a controller
Route::post('/orders', function (Request $request, CreateOrder $action) {
$order = $action->handle($request->user(), $request->input('items'));
return new OrderResource($order);
});
// From an Artisan command, e.g. backfilling test orders
$action->handle($customer, $seedItems);
// Or dispatched onto a queue when it doesn't need to block the request
dispatch(fn () => $action->handle($customer, $items));
Simple, single-model concerns (a scope, an accessor, a cast) still belong on the model. Actions earn their keep for operations that touch multiple models, external services, or side effects like notifications and queued jobs — the things that make a controller method balloon past a screen of code.
Why this makes testing easier
An Action class with constructor-injected dependencies is trivial to unit test in isolation — instantiate it with fakes or mocks for its dependencies, call handle(), assert on the result — without spinning up a full HTTP request/response cycle through a feature test. This doesn't replace feature tests for the controller/route wiring, but it means the actual business logic can be tested at the unit level, which tends to run faster and pinpoint failures more precisely than a feature test that fails because of unrelated middleware or validation.
When a plain method is still simpler
Not every controller method deserves an Action class. A straightforward CRUD update with no side effects beyond a single Eloquent save doesn't need the extra file and indirection — that's just adding ceremony for its own sake. The pattern earns its cost when logic is reused across contexts (web + API + queue), when a method has grown past simple CRUD, or when a business operation genuinely has enough steps that isolating it improves readability. Applying it to every single controller action uniformly just relocates the fat-controller problem into a directory of equally tangled Action classes.
Wrapping up
The Actions pattern's value is narrow and specific: it gives each meaningful business operation exactly one home, callable from however many entry points actually need it, instead of duplicating logic across a controller, a job, and a console command, or burying it inside a service class with too many responsibilities. Reach for it when logic needs to run from more than one context or a controller method has outgrown simple CRUD — not as a blanket rule for every action in the app.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.