spatie/pest-plugin-route-testing
Easily test every GET route in your Laravel app with Pest. Assert all routes return successful responses, include or exclude patterns, and bind models for routes with route model bindings.
Install as a dev dependency, then pick the version line deliberately — the package's major lines are pinned to Pest versions rather than Laravel versions:
composer require spatie/pest-plugin-route-testing --dev
If you're on 1.1.x, pin at least ^1.1.3 — 1.1.2 fixed an "Unknown named parameter $method" failure and 1.1.3 fixed TestResponse macros not being applied through __call (which is the mechanism every assertion in this package goes through).
Where to look first: create a single smoke-test file, ideally tests/Feature/RouteTest.php:
use function Spatie\RouteTesting\routeTesting;
routeTesting('all GET routes')->assertSuccessful();
Run it targeted, not as part of the whole suite:
./vendor/bin/pest tests/Feature/RouteTest.php
The first run is diagnostic, not a pass/fail gate. Read the output as three lists: routes that returned 200, routes that failed with a real status code (404s from missing controllers, 500s from broken views, 302s from auth middleware), and routes skipped because of unresolved route model bindings. That skip list is the most valuable artifact of day one — it tells you exactly how much of your app this test cannot see.
Your first real use case is usually triage by area rather than one big test: get public guest-accessible routes green first, then add authenticated and admin groups with setUp().
Split by audience, not by one giant test. A single "all routes" test is fine as a canary, but per-area files give you actionable failures and let each group carry its own pre-test state:
routeTesting('guest routes')
->exclude('admin*', 'account*', 'api*')
->assertSuccessful();
routeTesting('authenticated routes')
->setUp(function () {
$this->actingAs(User::factory()->create());
})
->include('account*')
->assertSuccessful();
Use the full TestResponse assertion surface, not just assertSuccessful(). This is the package's main leverage beyond a status-code smoke test:
routeTesting('legacy redirects')
->include('old-section/*')
->assertRedirect('new-section/*');
routeTesting('removed endpoints')
->include('v1/*')
->assertNotFound();
Bind models for anything with {model} parameters. Bindings are the difference between a test that covers your app and one that covers its static pages:
routeTesting('admin routes')
->setUp(function () {
$this->actingAs(User::factory()->create(['is_admin' => true]));
})
->bind('user', User::factory()->create())
->bind('post', Post::factory()->published()->create())
->include('admin/*')
->assertSuccessful();
Nested bindings (admin/users/{user}/posts/{post}) need every parameter bound or the route is skipped — bind them in one chain.
Pair with seeders for content-driven pages. An empty database will happily return 200 for an index page that renders nothing. If you care about the page actually working, seed representative data in setUp() (or your TestCase) so assertSuccessful() exercises real queries, pagination, and view composition.
Layer it under your feature tests. Treat route testing as a fast first-pass canary — run it as an early CI job, before the slower browser/feature suites. It catches "controller referenced a removed method" and "view has a typo" cheaply. It does not replace assertions about behaviour, authorization beyond status codes, or form submissions.
Snapshot responses for structural drift. Since 1.1.1 the plugin supports Pest snapshot assertions, so you can lock down the rendered output of a route and be told when markup changes. Useful for a handful of high-traffic pages; too noisy for all of them.
Green does not mean covered. GET-only plus default-skipped bindings means the headline "all routes pass" is false by construction on any app with a user/{user}-style URI. Call ->ignoreRoutesWithMissingBindings() only once you have decided you don't care about those routes — suppressing the output early hides your blind spot.
Patterns match the URI, not the route name, and wildcards are loose. include('blog*') also catches blogging-archive. Be explicit: include('blog', 'blog/*'). And exclude the framework/package routes you didn't write — _debugbar is excluded automatically since 1.0.1, but Horizon, Telescope, Pulse, Nova, Livewire, and sanctum/csrf-cookie are not. If your suite suddenly fails on a package upgrade, an unexcluded package route is the usual suspect.
302s and 401s are the most common surprise. Auth middleware redirecting to /login shows up as a assertSuccessful() failure, not as an error message about the middleware. If a route fails and works fine in the browser, you're almost certainly missing actingAs() in setUp().
Prefer the chained ->bind() over $this->bind() inside setUp(). The README shows both, but the chain is the more predictable path and keeps binding definitions next to the assertions they serve. Pick one style per file.
State that lives outside the request is on you. Middleware depending on tenancy context, subdomain-based routing (domain()), session flags, or feature-flagged config all need to be set up in setUp() or your base TestCase. The package makes a bare in-process HTTP request; anything your middleware assumes about the request environment must already be true.
Clear the route cache in CI. Routes are resolved from route:list, so a stale bootstrap/cache/routes-*.php from a previous deploy step can silently change what gets tested. Run the route test after a clean php artisan route:clear, or explicitly after route:cache if route caching is what production uses.
Debug one route at a time. Every route becomes its own test case, so --filter works well:
./vendor/bin/pest tests/Feature/RouteTest.php --filter="admin/users"
php artisan route:list --method=GET
The second command is what the plugin is consuming — if a route is missing from the run entirely, it's because it isn't in that list.
Extension points are deliberately thin: setUp(), bind(), include()/exclude(), ignoreRoutesWithMissingBindings(), and whatever custom TestResponse macros your app registers (working reliably from 1.1.3). There's no config file and no service provider to hook — if you need behaviour beyond that, wrap routeTesting() in your own helper function in tests/Pest.php rather than forking.
Version drift is the maintenance risk, not the code. The package version is a function of your Pest/PHP/Laravel position, and the 1.1.x branch is effectively legacy. Budget a composer require bump (plus a snapshot refresh, since snapshots are committed) whenever you move Pest majors.
How can I help you explore Laravel packages today?