sinemacula/laravel-route-linter
A Laravel package for linting application routes. It inspects registered routes and flags common problems, helping you catch misconfigurations early and keep route definitions clean, consistent, and maintainable.
The route linter is a dev-only quality tool, so it belongs in require-dev and should never reach production autoload.
composer require --dev sinemacula/laravel-route-linter
Publish the configuration next (the package ships a publishable config file — run php artisan vendor:publish and pick the route-linter entry, or check the package README for the exact --tag/--provider value, since tags vary between Sinemacula packages):
php artisan vendor:publish --tag=route-linter-config
Then run the linter command against your app (php artisan route:lint in recent versions — confirm via php artisan list | grep -i route after install, as command signatures have shifted between 1.x releases).
Where to look first:
config/route-linter.php — this is the real product surface. Rule toggles, severity levels, and exclusion lists all live here.--help output — flags for targeting a single route, a URI prefix, or a route name filter.routes/*.php files — the linter reports against what's actually registered, so route-file organization (and route caching) directly affects results.First use case: run it once on an existing codebase without changing any config. Treat the output as an inventory, not a to-do list. A mature app will light up with dozens of findings; the value in week one is discovering which rules your project already satisfies and which are simply noise for your domain (closure-based routes in a prototyping module, vendor-published routes, health checks). Only after that triage should you start enabling rules as CI gates.
CI gate with a baseline. The most common daily pattern is a non-blocking report locally and a blocking check in CI. Wire the command into your pipeline as a discrete step so failures are attributable:
- name: Lint routes
run: php artisan route:lint
Because the tool is rule-based rather than diff-based, adopt it incrementally: enable one rule family per PR, fix the findings, merge. Enabling everything at once on a legacy app produces a PR nobody can review.
Exclusions as documentation. Every exclusion you add to the config is a decision worth commenting. Group them by intent — // vendor / package routes, // Filament admin panels, // health & metrics endpoints — so the next developer understands why a route is exempt rather than assuming the linter is broken.
Pair with route:list for coverage sanity. The linter only sees registered routes. When you add a new route file or a service provider that registers routes conditionally, verify the linter picked it up:
php artisan route:list --path=api/v2
php artisan route:lint
Divergence between the two usually means a provider is gated behind an environment check or a feature flag that isn't active in your lint environment.
Per-environment config. Keep the lint strict locally (fast feedback, catch naming drift while you're writing the route) and looser in CI (or vice versa, depending on team culture). Laravel's config loading makes this trivial — read the rule severity from an env var rather than hardcoding it, so a hotfix branch can downgrade a rule without a code change.
New-project scaffolding. For greenfield work, enable the naming and conflict rules from day one. It costs nothing when the route file has nine entries and saves a painful retrofit at two hundred.
Local pre-push hook. A composer lint:routes script referenced from a Husky or Captainhook pre-push hook catches issues before they consume CI minutes. Route definitions change less often than application code, so the hook stays fast.
Route caching will lie to you. If bootstrap/cache/routes-*.php is stale, the linter inspects a snapshot that may not match your files. Always php artisan route:clear before linting in CI, or run the lint step before any route:cache step in the pipeline. This is the single most common source of "the linter says the route doesn't exist but it clearly does."
Laravel 13 support arrived in v1.2.0. If you're on Laravel 13, pin ^1.2. On Laravel 11/12, ^1.1 remains valid — but resist running a mixed matrix where some CI jobs resolve 1.1 and others 1.2, since rule defaults can drift between minor releases and produce inconsistent pass/fail results across jobs. Add composer update --lock discipline, or pin explicitly in composer.json.
Treat minor-version config changes as potentially breaking. Semantic-versioning packages like this often ship new default-on rules in a minor bump. After any composer update, diff your published config against the package's config/route-linter.php and decide deliberately whether to adopt new defaults. Silent new failures in CI the morning after a dependency bump are usually this.
Vendor and package routes are noise, not signal. Published routes from admin panels, debug toolbars, and auth scaffolding will dominate your output. Exclude by vendor/namespace prefix in config rather than deleting them from the report mentally each run — otherwise real findings get lost in the volume.
Closure routes can't be statically analyzed. Anything the linter checks about controller actions or middleware will be skipped or flagged for closures. If your app legitimately uses closures (health checks, simple redirects), configure them as expected rather than chasing false positives.
Extension points: the config file is the primary lever, but check whether the package exposes custom rule registration or a rule-interface you can implement for house conventions (e.g. "all api.* routes must have the api middleware group"). For team-specific invariants, a small custom rule beats a pile of brittle exclusions.
Debugging a surprising result: re-run with maximum verbosity (-vvv) and confirm the route's resolved URI, name, and action — the linter reports on the resolved route, so a Route::prefix() or group-level name() applied three files away may be the actual cause, not the line you're staring at.
How can I help you explore Laravel packages today?