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:
// 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:
// 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);
}
}
// 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:
// 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.
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.
// 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.
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:
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.
- Group Actions under their domain.
app/Actions/Orders/ApproveOrder.php, notapp/Actions/ApproveOrder.php. By the time you have thirty Actions, the flat folder is unsearchable. - Pick
executeorhandleor__invokeand use it everywhere. Don't mix. Pick one and put it in your team's docs. - Construct dependencies, pass data. Inject
StripeClientin the constructor; pass$orderand$userto the method. Constructors take services, methods take inputs. - 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.
- Don't dispatch events from the controller. The Action owns the rule, including the side effects. If
ApproveOrderdoesn't fireOrderApproved, 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.



