0

Action class — a single-purpose class encapsulating one use case

Beginner5 min read·eng-16-014
interviewsolid

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. PlaceOrderAction is unambiguous.
  • Single Responsibility: One reason to change.
  • Easy to find: Looking for "how does order cancellation work?" → CancelOrderAction.php.
  • Testable in isolation: Test PlaceOrderAction without testing OrderService's other methods.
  • Composable: Actions can call other actions.

Naming conventions: [Verb][Noun]ActionCreateUserAction, 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
<?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