0

Single-action controllers — __invoke()

Beginner5 min read·lv-08-002
interview

Concept

Single-action controllers implement the __invoke() magic method. Instead of having multiple action methods, the class itself IS the action. Route the controller class directly without specifying a method name.

When to use single-action controllers:

  • Complex operations that don't fit neatly into CRUD (ProcessPayment, ExportReport, SendBulkEmail).
  • Actions that benefit from dedicated service injection (the controller constructor can inject exactly what this one action needs).
  • Keeping each action focused and independently testable.

Benefits:

  • Single responsibility at the class level — one class, one action.
  • Clear naming: ProcessCheckout is more descriptive than OrderController@processCheckout.
  • Easier to find in the codebase — search for the class name.
  • Constructor can inject the exact dependencies needed for this one action.

Route definition: Route::post('/checkout', ProcessCheckout::class) — no method string needed. The router calls __invoke() automatically.

Naming: Use an action-oriented name: ProcessCheckout, ExportUserReport, ActivateAccount, SendPasswordReset. Avoid noun-only names that imply a resource controller.

Action classes vs single-action controllers: Both do one thing. Single-action controllers participate in the HTTP layer (middleware, request, response). Action classes (without routing) are pure business logic callable from anywhere.

Code Example

php
<?php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\PaymentService;
use App\Services\InventoryService;
use App\Events\OrderPlaced;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class ProcessCheckout extends Controller
{
    public function __construct(
        private readonly PaymentService   $payments,
        private readonly InventoryService $inventory,
    ) {}

    public function __invoke(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'cart_id'           => 'required|exists:carts,id',
            'payment_method_id' => 'required|string',
            'shipping_address'  => 'required|array',
        ]);

        $cart = Cart::with('items.product')->findOrFail($validated['cart_id']);

        // Verify inventory
        $this->inventory->reserveForCart($cart);

        // Process payment
        $paymentResult = $this->payments->charge(
            $cart->total,
            $validated['payment_method_id'],
        );

        // Create order
        $order = Order::createFromCart($cart, $paymentResult);
        event(new OrderPlaced($order));

        return response()->json([
            'order_id' => $order->id,
            'status'   => 'placed',
        ], 201);
    }
}

// Route definition — no method name needed
Route::post('/checkout', ProcessCheckout::class)->middleware(['auth:sanctum', 'throttle:orders']);

// More examples of well-named single-action controllers
Route::post('/orders/{order}/cancel', CancelOrder::class);
Route::post('/users/{user}/activate', ActivateUser::class);
Route::get('/reports/sales/export', ExportSalesReport::class);
Route::post('/password/reset', SendPasswordReset::class);

// Testing a single-action controller
class ProcessCheckoutTest extends TestCase
{
    public function test_checkout_creates_order(): void
    {
        // The controller class IS the action — test it directly or via HTTP
        $response = $this->actingAs($user)
            ->postJson('/checkout', ['cart_id' => $cart->id, ...]);
        $response->assertCreated()->assertJsonStructure(['order_id']);
    }
}