Laravel · Laravel

Laravel Actions Pattern — A Field Guide

The Actions pattern replaces fat controllers and grab-bag service classes with single-purpose invokable classes that work as controllers, jobs, and listeners all from one class.

John Kihiu12 min read

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.

PHP · app/Actions/Orders/CreateOrder.php
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.

PHP · routes/web.php + app/Console/Kernel or command
// 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));
Actions are not a replacement for Eloquent model logic

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.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.