Responsibilities
Controllers should be thin and focused:
- Handle HTTP requests and return HTTP responses
- Validate input via Form Requests
- Authorize actions via policies or gates
- Delegate business logic to Actions, Batches, or Pipelines
- Transform output via Resources (for APIs)
// Good - Thin controller
public function __invoke(StoreTicketRequest $request, CreateTicket $createTicket): RedirectResponse
{
$ticket = $createTicket->execute(TicketData::from($request));
return redirect()->route('tickets.show', $ticket);
}
// Bad - Fat controller with business logic
public function __invoke(Request $request): RedirectResponse
{
$validated = $request->validate([...]);
$ticket = new Ticket($validated);
$ticket->user_id = auth()->id();
$ticket->save();
Mail::to($ticket->user)->send(new TicketCreated($ticket));
Log::info('Ticket created', ['id' => $ticket->id]);
return redirect()->route('tickets.show', $ticket);
}
File Organization
app/Http/Controllers/
├── Controller.php
├── DashboardController.php
├── Tickets/
│ ├── IndexController.php
│ ├── ShowController.php
│ ├── StoreController.php
│ ├── UpdateController.php
│ └── DestroyController.php
├── Api/
│ └── Tickets/
│ ├── IndexController.php
│ └── ShowController.php
└── Auth/
├── LoginController.php
└── RegisterController.php
Naming Conventions
- Use singular nouns for the resource
- Use the
Controllersuffix - For single action controllers, name by action:
StoreController,DestroyController - Group related controllers in subdirectories
// Good
DashboardController
Tickets/StoreController
Tickets/UpdateController
Auth/LoginController
// Bad
TicketsController // Plural
CreateTicketController // Redundant when in Tickets/ directory
TicketCreator // Missing Controller suffix
Single Action Controllers
Prefer single action controllers using __invoke:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Tickets;
use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tickets\StoreTicketRequest;
use Illuminate\Http\RedirectResponse;
final class StoreController extends Controller
{
public function __invoke(
StoreTicketRequest $request,
CreateTicket $createTicket,
): RedirectResponse {
$ticket = $createTicket->execute(TicketData::from($request));
return redirect()
->route('tickets.show', $ticket)
->with('success', __('tickets.created'));
}
}
When to Use Single Action Controllers
- Each controller handles one action
- Makes controllers easier to find and navigate
- Follows single responsibility principle
- Simplifies testing
// Route definitions for single action controllers
Route::get('/tickets', Tickets\IndexController::class)->name('tickets.index');
Route::get('/tickets/create', Tickets\CreateController::class)->name('tickets.create');
Route::post('/tickets', Tickets\StoreController::class)->name('tickets.store');
Route::get('/tickets/{ticket}', Tickets\ShowController::class)->name('tickets.show');
Route::get('/tickets/{ticket}/edit', Tickets\EditController::class)->name('tickets.edit');
Route::put('/tickets/{ticket}', Tickets\UpdateController::class)->name('tickets.update');
Route::delete('/tickets/{ticket}', Tickets\DestroyController::class)->name('tickets.destroy');
Resource Controllers
For simple CRUD without complex logic, resource controllers are acceptable:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Category;
use App\Http\Requests\Categories\StoreCategoryRequest;
use App\Http\Requests\Categories\UpdateCategoryRequest;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
final class CategoryController extends Controller
{
public function index(): View
{
return view('categories.index', [
'categories' => Category::ordered()->get(),
]);
}
public function store(StoreCategoryRequest $request): RedirectResponse
{
Category::create($request->validated());
return redirect()
->route('categories.index')
->with('success', __('categories.created'));
}
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
$category->update($request->validated());
return redirect()
->route('categories.index')
->with('success', __('categories.updated'));
}
public function destroy(Category $category): RedirectResponse
{
$category->delete();
return redirect()
->route('categories.index')
->with('success', __('categories.deleted'));
}
}
Parameter Order
Order method parameters consistently:
- Route model bindings
- Form Request
- Injected dependencies
// Good
public function __invoke(
Ticket $ticket, // 1. Route model binding
UpdateTicketRequest $request, // 2. Form Request
UpdateTicket $updateTicket, // 3. Dependencies
): RedirectResponse {
// ...
}
// Bad - Inconsistent order
public function __invoke(
UpdateTicket $updateTicket,
UpdateTicketRequest $request,
Ticket $ticket,
): RedirectResponse {
// ...
}
Form Requests
Always use Form Requests for validation:
<?php
declare(strict_types=1);
namespace App\Http\Requests\Tickets;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreTicketRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or use policies
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:10'],
'priority' => ['required', Rule::in(['low', 'medium', 'high'])],
'category_id' => ['nullable', 'exists:categories,id'],
'tags' => ['nullable', 'array'],
'tags.*' => ['exists:tags,id'],
];
}
public function messages(): array
{
return [
'title.required' => __('validation.tickets.title_required'),
'body.min' => __('validation.tickets.body_min'),
];
}
}
Validation Rules Format
Use array syntax, not pipe syntax:
// Good - Array syntax
'email' => ['required', 'string', 'email', 'max:255'],
'status' => ['required', Rule::in(Status::cases())],
// Bad - Pipe syntax
'email' => 'required|string|email|max:255',
Authorization
In Form Request
public function authorize(): bool
{
return $this->user()->can('update', $this->route('ticket'));
}
In Controller
public function __invoke(Ticket $ticket): View
{
$this->authorize('view', $ticket);
return view('tickets.show', compact('ticket'));
}
Using Middleware
// In routes
Route::get('/admin', AdminController::class)->can('access-admin');
// Or in controller constructor
public function __construct()
{
$this->middleware('can:manage-tickets');
}
Responses
Web Responses
// Return a view
return view('tickets.show', [
'ticket' => $ticket,
'comments' => $ticket->comments()->latest()->get(),
]);
// Redirect to named route
return redirect()->route('tickets.index');
// Redirect with flash message
return redirect()
->route('tickets.show', $ticket)
->with('success', __('tickets.created'));
// Redirect back with errors
return back()->withErrors([
'email' => __('auth.failed'),
])->onlyInput('email');
// Redirect back with input
return back()->withInput();
API Responses
// Return resource
return new TicketResource($ticket);
// Return collection
return TicketResource::collection($tickets);
// Return with status code
return response()->json(
new TicketResource($ticket),
Response::HTTP_CREATED
);
// No content
return response()->noContent();
// Error response
return response()->json([
'message' => 'Ticket not found.',
], Response::HTTP_NOT_FOUND);
API Controllers
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Tickets;
use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreTicketRequest;
use App\Http\Resources\TicketResource;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class StoreController extends Controller
{
public function __invoke(
StoreTicketRequest $request,
CreateTicket $createTicket,
): JsonResponse {
$ticket = $createTicket->execute(TicketData::from($request));
return response()->json(
new TicketResource($ticket),
Response::HTTP_CREATED
);
}
}
Route Model Binding
Implicit Binding
// Route: /tickets/{ticket}
public function __invoke(Ticket $ticket): View
{
return view('tickets.show', compact('ticket'));
}
Custom Key
// Route: /tickets/{ticket:hash_id}
public function __invoke(Ticket $ticket): View
{
return view('tickets.show', compact('ticket'));
}
// Or in model
public function getRouteKeyName(): string
{
return 'hash_id';
}
Scoped Binding
// Route: /users/{user}/tickets/{ticket}
// Ensures ticket belongs to user
Route::get('/users/{user}/tickets/{ticket}', ShowController::class)
->scopeBindings();
Testing Controllers
<?php
declare(strict_types=1);
namespace Tests\Feature\Controllers\Tickets;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class StoreControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_ticket(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('tickets.store'), [
'title' => 'Test Ticket',
'body' => 'This is the ticket body content.',
'priority' => 'medium',
]);
$response->assertRedirect(route('tickets.show', Ticket::first()));
$response->assertSessionHas('success');
$this->assertDatabaseHas('tickets', [
'title' => 'Test Ticket',
'user_id' => $user->id,
]);
}
public function test_validation_errors_are_returned(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('tickets.store'), [
'title' => '',
'body' => 'short',
]);
$response->assertSessionHasErrors(['title', 'body', 'priority']);
}
public function test_guest_cannot_create_ticket(): void
{
$response = $this->post(route('tickets.store'), [
'title' => 'Test Ticket',
'body' => 'This is the ticket body content.',
'priority' => 'medium',
]);
$response->assertRedirect(route('login'));
}
}
Best Practices
Keep Controllers Thin
// Good - Controller only handles HTTP concerns
public function __invoke(
StoreOrderRequest $request,
ProcessOrder $processOrder,
): RedirectResponse {
$order = $processOrder->execute(OrderData::from($request));
return redirect()->route('orders.show', $order);
}
Use Dependency Injection
// Good - Inject via method
public function __invoke(
Ticket $ticket,
CloseTicket $closeTicket,
): RedirectResponse {
$closeTicket->execute($ticket);
return redirect()->route('tickets.show', $ticket);
}
// Avoid - Using app() helper
public function __invoke(Ticket $ticket): RedirectResponse
{
app(CloseTicket::class)->execute($ticket);
return redirect()->route('tickets.show', $ticket);
}
Type All Returns
// Good
public function __invoke(Ticket $ticket): View
public function __invoke(StoreRequest $request): RedirectResponse
public function __invoke(Ticket $ticket): JsonResponse
// Bad - Missing return type
public function __invoke(Ticket $ticket)