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');

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

GET

/authors

index

authors.index

GET

/authors/create

create

authors.create

POST

/authors

store

authors.store

GET

/authors/{author}

show

authors.show

GET

/authors/{author}/edit

edit

authors.edit

PUT/PATCH

/authors/{author}

update

authors.update

DELETE

/authors/{author}

destroy

authors.destroy

The variants, each checked with route:list in Bookshelf:

Definition Routes

Route::resource('authors', ...)->only(['index', 'show']) / ->except(['destroy'])

A subset of the seven.

Route::apiResource('loans', LoanController::class) (make:controller --api)

Five routes: no create or edit, which only show HTML forms.

Route::resource('books.reviews', ...)

Nested: /books/{book}/reviews/{review}, names books.reviews.*.

...->shallow()

index, create and store stay nested; the others become /reviews/{review}, since a review ID is unique.

...->scoped(['review' => 'slug'])

Nested and scoped: the child is looked up by slug and must belong to the parent, else 404.

Route::singleton('profile', ProfileController::class)

GET /profile, GET /profile/edit, PUT/PATCH /profile: one resource per user, no ID (->creatable() adds create, store and destroy).

->names([...]), ->parameters(['authors' => 'writer']), ->missing(fn ...), ->withTrashed()

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

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

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,
        ]);
    }
}

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

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 the App\Http\Controllers namespace 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 extending Illuminate\Routing\Controller; the base controller of new applications (Laravel 11) is an empty class, so use #[Middleware] (Laravel 13) or HasMiddleware (Laravel 11).

  • $this->authorize() from the AuthorizesRequests trait 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() and shallow() fix it, and Route::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.