sinemacula / laravel-route-linter
A deterministic, opt-in artisan linter for RESTful route conventions in Laravel applications.
Package info
github.com/sinemacula/laravel-route-linter
pkg:composer/sinemacula/laravel-route-linter
Requires
- php: ^8.3
- illuminate/console: *
- illuminate/routing: *
- illuminate/support: ^12.9
Requires (Dev)
- brianium/paratest: ^7.20
- friendsofphp/php-cs-fixer: ^3.94
- infection/infection: ^0.33.2
- orchestra/testbench: ^9.0 || ^10.0
- phpbench/phpbench: ^1.6.1
- phpstan/extension-installer: ^1.4
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- sinemacula/coding-standards: ^1.0
- slevomat/coding-standard: ^8.28
- squizlabs/php_codesniffer: ^4.0
This package is auto-updated.
Last update: 2026-06-18 22:20:10 UTC
README
A deterministic, opt-in Artisan command that lints a Laravel application's route table against a fixed catalogue of RESTful URL conventions and route-integrity checks, and exits non-zero on error-severity violations so CI can gate on it.
It reads the live route table (Router::getRoutes() after a full boot) plus its own config - no model versions, no
probabilistic inference - so the same routes and config always produce the same verdict. It enforces the
mechanically-checkable convention subset only; it is not a proof of true RESTfulness.
How It Works
The linter is built around a small set of ports and adapters, so the rule logic carries no framework dependency. One invocation walks the whole route table once:
- Source the app-owned routes from the live router, excluding vendor routes (the same set
route:list --except-vendorreports). - Normalise each route into a framework-free value object - its URI split into segments, its parameter names, its HTTP methods, its controller handler, and its gathered middleware.
- Inspect every route with the ordered per-route rules, then run the cross-route (aggregate) rules over the whole
set; each rule returns zero or more violations tagged
errororwarning. - Suppress any violation covered by an inline waiver or a config allowlist entry.
- Report the findings in a deterministic total order and exit non-zero when any
error-severity violation survives.
A few principles hold across the surface:
- Opt-in and deterministic. Nothing runs until you call
route:lint, and the same route table plus the same config yields a byte-identical verdict on every run, independent of route-cache state. - Every waiver is justified and per-rule. Waivers require a written reason and target specific rules. Unused waivers - and allowlist entries matching no live route - are surfaced as stale entries so they cannot rot (reported, but they do not gate).
- Misconfiguration fails loud. A malformed config value (a non-array where an array is expected, an exemption
missing its reason) raises an
InvalidConfigurationExceptionrather than silently weakening the verdict.
Rules
| Rule | Severity | Checks |
|---|---|---|
| R1 | error | No action verb in a path segment (incl. compound / pluralised), with a RESTful-rewrite hint |
| R2 | error | Segments are kebab-case |
| R3 | error | Segments are lowercase |
| R4 | error | Collection segments are plural (honours configured uncountables) |
| R5 | error | No trailing or duplicate slashes |
| R6 | error | No duplicate route name (would break route() URL generation) |
| R7 | error | Standard HTTP methods only |
| R8 | warning | Named routes follow {resource}.{action} |
| R9 | warning | No HTML-only create / edit action as the final literal segment on an API surface |
| R10 | warning | Routes matching a configured pattern declare the required middleware |
| R11 | warning | Resource nesting no deeper than the configured number of collection levels (default three) |
| R12 | error | Route handler (controller class / method) exists |
Note
Rule IDs are stable across releases - a rule keeps its ID for life, so waivers and CI gates that pin an ID stay valid on upgrade.
Installation
composer require --dev sinemacula/laravel-route-linter
The service provider is auto-discovered. Publish the config to tune it:
php artisan vendor:publish --tag=route-linter-config
Usage
php artisan route:lint
Exits non-zero when any error-severity violation is present (warnings are reported but do not gate). Run it as a step in CI.
Waiving a Violation
Every waiver requires a written reason and is per-rule. Unused waivers (and allowlist entries matching no live route) are surfaced as stale entries so they cannot rot - these are reported but do not gate.
Inline (preferred) - co-located at the route:
Route::patch('photos/{photo}/edit', [PhotoController::class, 'edit']) ->ignoreRouteLint(['R9'], 'legacy admin UI - BL-123'); // waives only R9 on this route Route::get('legacy/getStats', LegacyStatsController::class) ->ignoreRouteLint([], 'frozen v1 contract - BL-200'); // [] = all rules
Stored in the route action (survives route:cache).
Config allowlist - for routes you cannot annotate:
// config/route-linter.php 'exemptions' => [ ['match' => 'photos.edit', 'rules' => ['R9'], 'reason' => 'BL-123'], // per-rule ['match' => 'legacy.*', 'reason' => 'BL-200'], // rules omitted = all ],
Tuning
Removing a word from verb_denylist is rule tuning, not a per-route waiver - use it for legitimate domain-noun
homographs (e.g. a real transfer resource). This is global and needs no reason. The maximum nesting depth enforced by
R11 is set with nesting_max_depth (default 3).
R10 (required middleware) is opt-in and ships empty. Map an fnmatch URI pattern to the middleware a matching route
must declare; matching is an exact token comparison, so write parameterised middleware in full:
// config/route-linter.php 'required_middleware' => [ 'admin/*' => ['auth', 'can:access-admin'], 'api/*' => ['auth:sanctum'], ],
Extending
The rule set is the product surface, and it is configurable. The rules key lists the rules the engine runs, in order;
each is a class implementing the Rule contract and is resolved from the container, so rules may declare constructor
dependencies. Remove a built-in by deleting its line, or append your own:
// config/route-linter.php 'rules' => [ \SineMacula\RouteLinter\Rules\VerbInPathRule::class, // …the built-in rules… \App\RouteLinting\NoSnakeCaseRule::class, // your custom rule ],
A custom rule receives the normalised route - its segments, brace-stripped parameter names, controller handler
(Class@method, or null for closures), and gathered middleware - and the active config, and returns zero or more
violations:
use SineMacula\RouteLinter\Contracts\Rule; use SineMacula\RouteLinter\Dto\RuleConfig; use SineMacula\RouteLinter\NormalisedRoute; use SineMacula\RouteLinter\Severity; use SineMacula\RouteLinter\Violation; class NoSnakeCaseRule implements Rule { public function id(): string { return 'APP1'; } public function severity(): Severity { return Severity::ERROR; } public function inspect(NormalisedRoute $route, RuleConfig $config): array { $offenders = array_filter($route->segments, static fn (string $s): bool => str_contains($s, '_')); return array_map(fn (string $s): Violation => new Violation( ruleId: $this->id(), severity: $this->severity(), routeIdentity: $route->identity(), offendingSurface: $s, remediationHint: null, ), array_values($offenders)); } }
For checks that span the whole route table rather than one route at a time - duplicate detection, table-wide invariants
- implement
AggregateRuleinstead. Itsinspect(array $routes, RuleConfig $config)receives every normalised route at once and runs in a single pass after the per-route rules. List it in the sameruleskey; the engine partitions the two kinds by contract. Attribute each violation to the offending route'sidentity()so per-rule waivers still apply.
Rule IDs must be unique across both kinds - the engine rejects a duplicate at boot. Output rendering is a port too: bind
your own LintReporter implementation (for example, to emit JSON or SARIF for CI) to replace the default console
reporter.
Determinism
The same route table plus the same config yields a byte-identical verdict on every run, independent of route-cache state. It enforces the mechanically-checkable convention subset only - it is not a proof of true RESTfulness.
Requirements
- PHP ^8.3
- Laravel ^12.9
Testing
composer test # Run the test suite in parallel using Paratest composer test:coverage # With clover coverage report composer test:mutation # Mutation-testing gate (Infection) - the enforced MSI floor composer test:mutation:full # Full mutation suite, no thresholds (scheduled audit run) composer check # Static analysis and lint checks via qlty composer format # Format the codebase via qlty composer smells # Advisory code smells (duplication, complexity) composer bench # Run the PHPBench benchmarks
Changelog
See CHANGELOG.md for a list of notable changes.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.