Controllers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in
the Bookshelf application used across this guide. A controller is a class in app/Http/Controllers whose public
methods (actions) handle requests; routes point at them with [Controller::class, 'method']. Routing itself, route
model binding and URL generation are on Routing, and the middleware a controller can
declare is on Middleware.
Basic and single-action controllers
php artisan make:controller BookController creates a class extending the application’s (empty) abstract
App\Http\Controllers\Controller. Each action receives route parameters and anything the container can resolve, and
returns a view, an array (sent as JSON), a redirect or any response:
<?php
// app/Http/Controllers/BookController.php
namespace App\Http\Controllers;
use App\Models\Book;
use Illuminate\View\View;
class BookController extends Controller
{
public function index(): View
{
$books = Book::with('author')->orderBy('title')->paginate(10);
return view('books.index', ['books' => $books]);
}
public function show(Book $book): View // route model binding
{
return view('books.show', ['book' => $book->load('author', 'reviews')]);
}
}
<?php
// routes/web.php
use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;
Route::get('/books', [BookController::class, 'index'])->name('books.index');
Route::get('/books/{book}', [BookController::class, 'show'])->name('books.show');
Source: Laravel — Basic Controllers
A controller with one job can be single-action: make:controller ShelfStatsController --invokable generates an
__invoke() method, and the route names the class alone:
<?php
// app/Http/Controllers/ShelfStatsController.php
namespace App\Http\Controllers;
use App\Models\Book;
use App\Models\Review;
class ShelfStatsController extends Controller
{
/** A single-action controller: the route points at the class. */
public function __invoke(): array
{
return [
'books' => Book::count(),
'reviews' => Review::count(),
'average_rating' => round((float) Review::avg('rating'), 2),
];
}
}
// routes/web.php
Route::get('/stats', App\Http\Controllers\ShelfStatsController::class);
$ curl -s localhost:8000/stats
{"books":25,"reviews":1,"average_rating":5}
Resource controllers
php artisan make:controller AuthorController --resource --model=Author generates the seven conventional actions
with the model type-hinted, and one Route::resource() line registers all of them. The map, as route:list reports
it for Route::resource('authors', AuthorController::class):
| Verb | URI | Action | Route name |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The variants, each checked with route:list in Bookshelf:
| Definition | Routes |
|---|---|
|
A subset of the seven. |
|
Five routes: no |
|
Nested: |
|
|
|
Nested and scoped: the child is looked up by |
|
|
|
Rename routes or parameters, customise the 404 of a missing model, include soft-deleted models. |
The nested, shallow review routes used below:
// routes/web.php
Route::resource('books.reviews', App\Http\Controllers\BookReviewController::class)
->only(['index', 'store', 'show', 'destroy'])
->shallow();
$ php artisan route:list --name=reviews
GET|HEAD books/{book}/reviews books.reviews.index › BookReviewController@…
POST books/{book}/reviews books.reviews.store › BookReviewController@…
GET|HEAD reviews/{review} ....... reviews.show › BookReviewController@show
DELETE reviews/{review} . reviews.destroy › BookReviewController@destroy
Source: Laravel — Shallow Nesting
Without shallow() or scoped(), the two bindings of a nested route are independent: in Bookshelf, review 1 belongs
to book 1, yet /books/2/reviews/1 on an unscoped route returned [2,1] (book 2, a review of book 1), while the same
route with ->scopeBindings() returned 404. Scoping (or shallow routes that never pretend a parent exists) closes
that hole; see the scoped binding example on Routing. Additional routes for a
resource controller must be registered before the Route::resource() call, or /authors/popular is captured by
/authors/{author}.
Controller middleware and authorization attributes
Laravel 13 declares controller middleware and policy checks with attributes from
Illuminate\Routing\Attributes\Controllers. #[Middleware] on the class applies to every action (with only: and
except:), on a method to that action; #[Authorize(ability, parameters)] is a shortcut for the can: middleware,
passing the named route parameter (or a class name, for abilities like create) to the policy:
<?php
// app/Http/Controllers/BookReviewController.php
namespace App\Http\Controllers;
use App\Actions\PostReview;
use App\Models\Book;
use App\Models\Review;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth', except: ['index', 'show'])]
class BookReviewController extends Controller
{
public function index(Book $book) // GET /books/{book}/reviews
{
return $book->reviews()->latest()->get(['id', 'rating', 'body']);
}
public function store(Request $request, Book $book, PostReview $postReview): RedirectResponse
{
$data = $request->validate(['rating' => 'required|integer|between:1,5', 'body' => 'required|string|max:2000']);
$review = $postReview->handle($request->user(), $book, $data['rating'], $data['body']);
return redirect()->route('reviews.show', $review)->with('status', 'Review posted');
}
public function show(Review $review) // shallow: GET /reviews/{review}
{
return $review->only('id', 'book_id', 'rating', 'body');
}
#[Authorize('delete', 'review')]
public function destroy(Review $review): RedirectResponse // shallow: DELETE /reviews/{review}
{
$review->delete();
return redirect()->route('books.reviews.index', $review->book_id);
}
}
<?php
// app/Policies/ReviewPolicy.php (php artisan make:policy ReviewPolicy --model=Review)
namespace App\Policies;
use App\Models\Review;
use App\Models\User;
class ReviewPolicy
{
public function delete(User $user, Review $review): bool
{
return $user->id === $review->user_id;
}
}
The attributes become ordinary route middleware, which route:list -v shows:
$ php artisan route:list -v --name=reviews
GET|HEAD books/{book}/reviews books.reviews.index › BookReviewController@index
⇂ web
POST books/{book}/reviews books.reviews.store › BookReviewController@store
⇂ web
⇂ auth
GET|HEAD reviews/{review} ....... reviews.show › BookReviewController@show
⇂ web
DELETE reviews/{review} . reviews.destroy › BookReviewController@destroy
⇂ web
⇂ auth
⇂ Illuminate\Auth\Middleware\Authorize:delete,review
Source: Laravel — Listing Your Routes
The policy is discovered by name (Review model, ReviewPolicy in app/Policies); policies themselves are on
Authorization: Gates and Policies. A POST from curl
without a CSRF token gets 419 before auth is even reached, because PreventRequestForgery in the web group runs
first. The alternative to attributes, available since Laravel 11, is implementing HasMiddleware with a static
middleware() method returning new Middleware('auth', except: [...]) objects (from
Illuminate\Routing\Controllers, a different namespace from the attribute), or adding ->middleware() to the route.
Dependency injection
Controllers are resolved from the service container. Constructor parameters are injected once per controller
instance; action parameters are injected per call, after the route parameters are bound, in any order. store()
above receives the Request, the bound Book and a PostReview action. Constructor injection suits collaborators
that most actions use:
<?php
// app/Http/Controllers/LoanController.php (constructor injection)
namespace App\Http\Controllers;
use App\Services\FineCalculator;
class LoanController extends Controller
{
public function __construct(private FineCalculator $fines) {}
public function show(string $loan): array
{
return ['loan' => $loan, 'fine_cents' => $this->fines->fineFor(daysLate: 4)];
}
}
How the container decides what to inject (autowiring, #[Config] and other contextual attributes, interface
bindings) is on Service Container, Providers and
Facades.
Thin controllers and action classes
The MVC split taught by the 2010-era framework books (Porębski, Przystalski and Nowak, Wiley) still holds: the controller translates HTTP into a call and the result back into HTTP, the model owns the data rules, the view renders. What changed is where the logic between them goes. Instead of growing fat controllers or "fat models", Laravel applications usually put each use case in a small action (or service) class with one public method, which a controller, an Artisan command, a queued job and a test can all call:
<?php
// app/Actions/PostReview.php
namespace App\Actions;
use App\Models\Book;
use App\Models\Review;
use App\Models\User;
/** One use case, callable from a controller, a command, a job or a test. */
class PostReview
{
public function handle(User $user, Book $book, int $rating, string $body): Review
{
return $book->reviews()->create([
'user_id' => $user->id,
'rating' => $rating,
'body' => $body,
]);
}
}
Source: Laravel — Automatic Injection
There is no make:action command and no required base class: it is a plain class the container autowires. A rule of
thumb that keeps controllers thin: an action method validates (or receives a form request), calls one domain method,
and returns a response. Validation moves out with Form Requests
(Validation and Form Requests), authorization with policies,
and formatting with API Resources (Building APIs and API
Resources).
Testing controllers
Controllers are tested through HTTP, not by calling their methods. The feature tests below check the action, the
auth attribute and the #[Authorize] policy check, on an in-memory SQLite database (phpunit.xml of a new
application):
<?php
// tests/Feature/BookReviewControllerTest.php
use App\Models\Book;
use App\Models\Review;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets a signed-in reader post a review through the action', function () {
$book = Book::factory()->create();
$reader = User::factory()->create();
$this->actingAs($reader)
->post("/books/{$book->id}/reviews", ['rating' => 4, 'body' => 'Gripping.'])
->assertRedirect()
->assertSessionHas('status', 'Review posted');
expect($book->reviews()->sole())->user_id->toBe($reader->id)->rating->toBe(4);
});
it('requires authentication to post', function () {
$book = Book::factory()->create();
$this->postJson("/books/{$book->id}/reviews", ['rating' => 4, 'body' => 'x'])->assertUnauthorized();
});
it('authorizes deletion with the policy via #[Authorize]', function () {
$review = Review::factory()->create();
$this->actingAs(User::factory()->create())->delete("/reviews/{$review->id}")->assertForbidden();
$this->actingAs($review->user)->delete("/reviews/{$review->id}")->assertRedirect("/books/{$review->book_id}/reviews");
$this->assertModelMissing($review);
});
$ php artisan test --filter=BookReviewController
PASS Tests\Feature\BookReviewControllerTest
✓ it lets a signed-in reader post a review through the action 0.21s
✓ it requires authentication to post 0.01s
✓ it authorizes deletion with the policy via #[Authorize] 0.02s
Tests: 3 passed (9 assertions)
Duration: 0.31s
Source: Laravel — HTTP Tests
Tests do not need a CSRF token: PreventRequestForgery is skipped while running unit tests. The test API is covered
on Testing.
Where older books and tutorials are outdated
-
Route::get('/', 'BookController@index')strings and theApp\Http\Controllersnamespace prefix (Stauffer ch. 3) stopped working in new applications in Laravel 8; reference the class:[BookController::class, 'index']. -
$this->middleware('auth')->only(...)in the controller constructor (Stauffer ch. 3) relied on the base controller extendingIlluminate\Routing\Controller; the base controller of new applications (Laravel 11) is an empty class, so use#[Middleware](Laravel 13) orHasMiddleware(Laravel 11). -
$this->authorize()from theAuthorizesRequeststrait is likewise not on the new base controller; use#[Authorize],Gate::authorize()or$request->user()->can(). -
Route::resource()without scoping on nested resources let any child ID through;scoped()andshallow()fix it, andRoute::singleton()(Laravel 9) covers resources that have no ID. -
Logic-heavy controllers or models in MVC tutorials (Wiley) are usually split into action or service classes and Form Requests today.