Single-action controllers — __invoke()
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:
ProcessCheckoutis more descriptive thanOrderController@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
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']);
}
}