Action class — a single-purpose class encapsulating one use case
Concept
Action class — a single-purpose class that encapsulates exactly ONE use case. One class, one public method (usually execute() or __invoke()), one responsibility.
Where it sits: Between a controller and a service. More granular than a service (which may have many methods). An action IS one service method, but extracted into its own class.
Benefits:
- Clarity: The class name IS the documentation.
PlaceOrderActionis unambiguous. - Single Responsibility: One reason to change.
- Easy to find: Looking for "how does order cancellation work?" →
CancelOrderAction.php. - Testable in isolation: Test
PlaceOrderActionwithout testingOrderService's other methods. - Composable: Actions can call other actions.
Naming conventions: [Verb][Noun]Action — CreateUserAction, SendWelcomeEmailAction, ProcessPaymentAction, GenerateInvoiceAction.
Controller usage: The controller instantiates (or injects) the action and calls it. The controller handles HTTP, the action handles the business logic.
__invoke() as the entry point: A single __invoke() method makes the class callable — $action($params). Alternative: explicit execute() or handle() method.
Larry Garfield / Laravel ecosystem: Action classes are popular in Laravel projects. Packages like lorisleiva/laravel-actions make them first-class — the same action class can be used as a controller, job, event listener, or console command.
Code Example
<?php
// SIMPLE ACTION CLASS
class PlaceOrderAction
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly ProductRepositoryInterface $products,
private readonly PaymentGateway $payments,
) {}
public function execute(User $user, array $items, string $paymentToken): Order
{
return \DB::transaction(function () use ($user, $items, $paymentToken) {
$order = Order::create(['user_id' => $user->id, 'status' => 'pending']);
$total = 0;
foreach ($items as $item) {
$product = $this->products->findWithLock($item['product_id']);
if ($product->stock < $item['quantity']) throw new InsufficientStockException($product);
$product->decrement('stock', $item['quantity']);
$order->items()->create($item);
$total += $product->price * $item['quantity'];
}
$result = $this->payments->charge((int)($total * 100), 'usd', $paymentToken);
if (!$result->success) throw new PaymentFailedException($result->message);
$order->update(['total' => $total, 'status' => 'paid', 'charge_id' => $result->transactionId]);
event(new OrderPlaced($order));
return $order;
});
}
}
// Using __invoke() — makes the action callable
class CancelOrderAction
{
public function __invoke(Order $order): void
{
if ($order->status === 'cancelled') return;
if (!in_array($order->status, ['pending', 'paid'])) {
throw new \DomainException("Cannot cancel {$order->status} order");
}
$order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
RefundPaymentAction::run($order); // compose actions!
event(new OrderCancelled($order));
}
}
// CONTROLLER — thin, delegates to action
class OrderController extends Controller
{
public function store(CreateOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
$order = $action->execute($request->user(), $request->items, $request->payment_token);
return response()->json(new OrderResource($order), 201);
}
public function destroy(Order $order, CancelOrderAction $cancel): JsonResponse
{
$this->authorize('cancel', $order);
($cancel)($order); // __invoke style
return response()->noContent();
}
}
// laravel-actions package — same class as action, job, controller, command
use Lorisleiva\Actions\Concerns\AsAction;
class GenerateMonthlyReport
{
use AsAction;
public string $commandSignature = 'reports:monthly {month}'; // also works as Artisan command
public function handle(\Carbon\Carbon $month): Report
{
return Report::create([
'month' => $month->format('Y-m'),
'content' => $this->generate($month),
]);
}
private function generate(\Carbon\Carbon $month): array { /* ... */ return []; }
}
// Can be used as:
GenerateMonthlyReport::run($month); // as action
GenerateMonthlyReport::dispatch($month); // as job
// Route::get('/reports/generate', GenerateMonthlyReport::class); // as controller