Appearance
Actions
Keep controllers thin. Business logic that would otherwise bloat a controller method (or get duplicated across a controller, a job, and a console command) should live in an action class instead: a plain PHP class with a single public method (conventionally handle()) that does one thing.
Why actions
Controllers tend to accumulate responsibilities over time: validation, authorization, the actual business logic, and side effects like notifications or third-party API calls all end up in the same method. Once that logic is also needed from a console command, a queued job, or another controller, it either gets duplicated or the controller gets injected into places it was never meant to live.
An action fixes this by giving each piece of business logic exactly one class:
- Single responsibility -
PlaceOrderdoes one thing, and its name says what. - Reusable - the same class can be called from a controller, a command, a job, or another action, without rewriting the logic for each context.
- Testable in isolation - unit test
PlaceOrder::handle()directly without booting HTTP routing or middleware. - Composable - actions can call other actions, so complex workflows are built from small, independently testable steps rather than one long method.
A basic action
An action is just a class. No base class or interface is required:
php
<?php
namespace App\Actions;
use App\Models\Order;
class PlaceOrder
{
public function handle(Order $order): void
{
$order->placed_at = $order->freshTimestamp();
$order->save();
// ...
}
}Because it's a normal class, Laravel's service container will resolve it (and any dependencies it declares) wherever it's type-hinted, so it can be injected straight into a controller method:
php
public function place(Request $request, PlaceOrder $placeOrder)
{
$placeOrder->handle($order);
// ...
}Refactoring a controller
Take a controller that validates the request, creates the order, charges the customer, and emails a confirmation, all in one method:
php
<?php
namespace App\Http\Controllers;
use App\Mail\OrderConfirmation;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class OrdersController extends Controller
{
public function place(Order $order, Request $request)
{
$validated = $request->validate([
'payment_token' => ['required', 'string'],
]);
$order->charge($validated['payment_token']);
$order->placed_at = $order->freshTimestamp();
$order->save();
Mail::to($order->user)->send(new OrderConfirmation($order));
return redirect()->route('orders.show', $order);
}
}Everything below validation is really 'place an order', not 'handle this HTTP request'. Move it into an action:
php
<?php
namespace App\Actions;
use App\Mail\OrderConfirmation;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
class PlaceOrder
{
public function handle(Order $order, string $paymentToken): void
{
$order->charge($paymentToken);
$order->placed_at = $order->freshTimestamp();
$order->save();
Mail::to($order->user)->send(new OrderConfirmation($order));
}
}The controller keeps the HTTP concerns (validation, the redirect) and delegates the rest:
php
<?php
namespace App\Http\Controllers;
use App\Actions\PlaceOrder;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function store(Order $order, Request $request, PlaceOrder $placeOrder)
{
$validated = $request->validate([
'payment_token' => ['required', 'string'],
]);
$placeOrder->handle($order, $validated['payment_token']);
return redirect()->route('orders.show', $order);
}
}PlaceOrder is now unit-testable on its own, and reusable from a command or another action without touching HTTP at all.
Actions calling other actions
Once logic lives in actions, larger workflows are built by composing smaller ones rather than growing a single handle() method. For example, placing an order should also export it to the CRM, but that's a distinct concern with its own failure modes:
php
<?php
namespace App\Actions;
use App\Models\Order;
class ExportOrderToCrm
{
public function handle(Order $order): void
{
Crm::orders()->create([
'reference' => $order->id,
'total' => $order->total,
'customer_email' => $order->user->email,
]);
}
}PlaceOrder depends on it like any other collaborator, declared as a constructor argument and resolved by the container:
php
<?php
namespace App\Actions;
use App\Mail\OrderConfirmation;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
class PlaceOrder
{
public function __construct(
private readonly ExportOrderToCrm $exportOrderToCrm,
) {}
public function handle(Order $order, string $paymentToken): Order
{
$order->charge($paymentToken);
$order->placed_at = $order->freshTimestamp();
$order->save();
Mail::to($order->user)->send(new OrderConfirmation($order));
$this->exportOrderToCrm->handle($order);
return $order;
}
}PlaceOrder's own tests don't need a real CRM connection, they only need to assert that ExportOrderToCrm::handle() was called. If the CRM call is slow and shouldn't hold up the response, wrap that one call in a queued job rather than calling it inline, the rest of the action is unaffected.
The laravel-actions package
The pattern above works with plain classes and needs nothing extra. For projects that want less boilerplate around wiring an action up as a route, job, command, or listener, we use lorisleiva/laravel-actions:
bash
composer require lorisleiva/laravel-actionsAdding the AsAction trait lets the same class double as a controller, a queued job, an Artisan command, or an event listener, without writing separate classes for each:
php
<?php
namespace App\Actions;
use App\Models\Order;
use Lorisleiva\Actions\Concerns\AsAction;
class PlaceOrder
{
use AsAction;
public function handle(Order $order): void
{
$order->markAsPlaced();
}
}php
// Called directly, same as before
PlaceOrder::run($order);
// Wired straight into a route
Route::post('orders/{order}/place', PlaceOrder::class);
// Dispatched onto the queue
PlaceOrder::dispatch($order);Further reading
- loreisleiva/laravel-actions documentation
- Spatie's Laravel Beyond CRUD course covers this pattern (and other approaches to structuring larger Laravel apps) in more depth. Login details are stored in BitWarden.