You open OrderController.php for the first time in three months and the store method is 240 lines. There's input shaping, inventory checking, a DB::transaction, a Stripe call, a webhook signature verification, three queue dispatches, and a Slack notification at the bottom. The method works. Nobody on the team can describe what it does without scrolling.

Two weeks later, the product team asks for an "approve order" Artisan command for their internal back-office. The fastest path is to copy the relevant 80 lines out of OrderController@store into the command, change a few imports, and ship it. Six months later you fix a bug in one place and somebody pages you because the other place still has it.

That's the gap Actions fill. Not Services. Not Repositories. A class with one verb, one method, one reason to exist — and the same call shape from a controller, a job, a command, a webhook handler, or a test.

What An Action Actually Is

In Laravel terms, an Action is just a class. There's no base class, no trait, no package required. It looks like this:

PHP
// app/Actions/Orders/ApproveOrder.php
namespace App\Actions\Orders;

use App\Events\OrderApproved;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final class ApproveOrder
{
    public function execute(Order $order, User $approver, ?string $notes = null): Order
    {
        return DB::transaction(function () use ($order, $approver, $notes) {
            $order->fill([
                'status'      => 'approved',
                'approved_by' => $approver->id,
                'approved_at' => now(),
                'notes'       => $notes,
            ])->save();

            OrderApproved::dispatch($order->fresh());

            return $order;
        });
    }
}

Three constraints, applied ruthlessly: one class per use case, named for the use case, with one public method. The method name doesn't matter — execute, handle, __invoke are all fine; pick one and use it everywhere. What matters is that ApproveOrder does exactly one thing and the file you open shows you all of it.

The win is that everything you'd normally argue about — Service vs Manager vs UseCase vs Interactor — collapses to a folder of single-verb classes. You don't grep for which of seventeen methods on OrderService does the approving. The class name is the answer.

How It Replaces A Fat Controller

The 240-line controller from the start of this article becomes three small files:

PHP
// app/Http/Controllers/Orders/ApproveOrderController.php
namespace App\Http\Controllers\Orders;

use App\Actions\Orders\ApproveOrder;
use App\Http\Requests\Orders\ApproveOrderRequest;
use App\Http\Resources\OrderResource;
use App\Models\Order;

final class ApproveOrderController
{
    public function __invoke(
        ApproveOrderRequest $request,
        Order $order,
        ApproveOrder $action,
    ): OrderResource {
        $approved = $action->execute(
            $order,
            $request->user(),
            $request->validated('notes'),
        );

        return OrderResource::make($approved);
    }
}
PHP
// app/Http/Requests/Orders/ApproveOrderRequest.php
namespace App\Http\Requests\Orders;

use Illuminate\Foundation\Http\FormRequest;

final class ApproveOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('approve', $this->route('order'));
    }

    public function rules(): array
    {
        return ['notes' => ['nullable', 'string', 'max:500']];
    }
}

The Form Request handles authorization and input shape at the edge. The controller is one method long and does nothing but wire dependencies through to the Action. The Action owns the transaction, the writes, and the event. Each file fits on a screen. Each file has one reason to change.

The reuse story is the part that pays off later. The same ApproveOrder is callable from anywhere:

PHP
// From an Artisan command
class ApproveOrderCommand extends Command
{
    public function handle(ApproveOrder $action): int
    {
        $order = Order::findOrFail($this->argument('id'));
        $action->execute($order, User::find($this->option('approver')));

        return Command::SUCCESS;
    }
}

// From a queued job
class ApproveOrdersFromBatchJob implements ShouldQueue
{
    public function handle(ApproveOrder $action): void
    {
        Order::pendingApproval()->each(fn ($order) =>
            $action->execute($order, User::system())
        );
    }
}

// From a webhook handler
class StripeApprovalWebhookController
{
    public function __invoke(Request $request, ApproveOrder $action) { /* ... */ }
}

Same call. No copy-paste. The Action doesn't know whether it was called from HTTP, the CLI, a queue, or a test — and that's exactly the property you want.

Diagram showing the same ApproveOrder Action being invoked from four entry points: HTTP controller, Artisan command, queued job, and webhook handler. Each entry point handles its own concern (HTTP validation, command flags, batch iteration, signature verification) and then passes a normalized Order + User into the single Action::execute method. Below, the Action does its work — transaction, save, event dispatch — then returns.
One Action, four entry points — the call shape is the same everywhere

Testing An Action Is Boring (That's The Point)

Because an Action is a plain class with explicit dependencies, you don't need the HTTP kernel to test it.

PHP
// tests/Unit/Actions/Orders/ApproveOrderTest.php
use App\Actions\Orders\ApproveOrder;
use App\Events\OrderApproved;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Event;

it('marks an order approved and records the approver', function () {
    Event::fake([OrderApproved::class]);

    $order    = Order::factory()->pending()->create();
    $approver = User::factory()->manager()->create();

    $result = (new ApproveOrder())->execute($order, $approver, 'Looks good');

    expect($result->status)->toBe('approved')
        ->and($result->approved_by)->toBe($approver->id)
        ->and($result->notes)->toBe('Looks good');

    Event::assertDispatched(OrderApproved::class);
});

The test runs in milliseconds. It doesn't care how the Action gets called in production — the rule it tests (orders get marked approved with the approver recorded and an event dispatched) holds regardless of entry point. When you later add the Artisan command, the webhook handler, and the queued job, every one of them is covered by this single unit test.

That's the second-order win of the pattern: the test count grows linearly with the number of rules, not quadratically with the number of entry points.

A boundary sketch of a Laravel request crossing four lanes — HTTP Edge (Controller plus FormRequest doing authorize and validation), Application Core (the Action class with its execute method), Persistence (Eloquent inside a DB::transaction), and Side Effects (events fanning out to queued listeners, mail, push, search). Arrows show the request flowing across each border, and red markers note where each lane fails distinctly: 422 at the edge, 403 from authorize, a deadlock in persistence, and a retried job in side effects.
Each lane owns one kind of failure — that's how the Action knows what's its job and what isn't.

When To Reach For lorisleiva/laravel-actions

There's a popular package, lorisleiva/laravel-actions, that lets you turn one class into all of: a controller, a job, a listener, an Artisan command, and a programmatic call. It looks like this:

PHP
use Lorisleiva\Actions\Concerns\AsAction;

final class ApproveOrder
{
    use AsAction;

    public function handle(Order $order, User $approver, ?string $notes = null): Order
    {
        // ...same body
    }

    // optional: HTTP layer
    public function asController(ActionRequest $request, Order $order): OrderResource { /* ... */ }

    // optional: Artisan layer
    public function asCommand(Command $command): void { /* ... */ }
}

It's elegant. It's also a layer of magic on top of vanilla PHP, and the failure mode is that "what does this class actually do?" answers stop being grep-able. My advice: write the vanilla version first. If after six months you find yourself maintaining an ApproveOrder Action and a separate ApproveOrderController and a separate ApproveOrderCommand that all delegate to the same Action, the package's as* methods can collapse them into one file. Until then, three small files are easier to reason about than one clever one.

The package is good. The pattern doesn't need it.

Where Actions Stop Being The Right Tool

Actions are not the answer to every architecture question. A few cases where something else fits better.

Long-lived state. A multi-step checkout wizard, a per-request shopping cart, a stateful import session — these aren't single-verb operations. A Service class with add, remove, total, flush methods is the right shape. The Action constraint of "one method, takes inputs, returns outputs" works against you.

Coordinating an external system across many operations. A StripeBillingService that wraps charge, refund, sync, cancel, and webhook handling has four cohesive methods sharing a constructor and a set of concerns (idempotency keys, retries, exception types). Splitting it into four Actions costs you that cohesion.

Read queries. An "Action" called ListPendingOrders is just a query. Put it on a model scope, a query class, or a controller. The Action pattern is for commands — operations that change state. Read paths don't need the same ceremony.

Pure model state. $invoice->isPastDue(), $user->hasVerifiedEmail() — these belong on the model. They're row-level predicates, not multi-step use cases. Actions are for the orchestration around the model, not the row's own logic.

The rule that holds up: if the use case has one verb and changes state, it's an Action. Everything else is something else.

A Few Habits That Make The Pattern Stick

A short list of conventions that turn "we use Actions" from a vague claim into a repeatable practice.

  1. Group Actions under their domain. app/Actions/Orders/ApproveOrder.php, not app/Actions/ApproveOrder.php. By the time you have thirty Actions, the flat folder is unsearchable.
  2. Pick execute or handle or __invoke and use it everywhere. Don't mix. Pick one and put it in your team's docs.
  3. Construct dependencies, pass data. Inject StripeClient in the constructor; pass $order and $user to the method. Constructors take services, methods take inputs.
  4. Return what the caller needs. A controller wants the model, a job wants nothing. Action returns the model; controllers wrap in resources, jobs ignore the return.
  5. Don't dispatch events from the controller. The Action owns the rule, including the side effects. If ApproveOrder doesn't fire OrderApproved, nothing else should either.

These aren't deep insights; they're the kind of consistency that lets a new developer open app/Actions/Orders/ six months from now and know exactly where to put the next thing.

A One-Sentence Mental Model

An Action is one class, one verb, one method — ApproveOrder::execute() — and the value isn't the class itself but the constraint it imposes: every state-changing use case has exactly one home, every entry point (HTTP, CLI, queue, webhook) calls it the same way, every test covers the rule once, and "fat controller" stops being a thing your codebase can drift into.