sinemacula/laravel-route-linter
Composer 安装命令:
composer require sinemacula/laravel-route-linter
包简介
A deterministic, opt-in artisan linter for RESTful route conventions in Laravel applications.
README 文档
README
A deterministic, opt-in Artisan command that lints a Laravel application's route table against a fixed catalogue of RESTful URL conventions, 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, and its HTTP methods.
- Inspect every route with the ordered rule 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 |
| 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 |
| R11 | warning | Resource nesting no deeper than the configured number of collection levels (default three) |
Note
Rule IDs R6 and R10 are intentionally reserved/retired - IDs are kept stable across releases.
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).
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 (including its brace-stripped parameter names) 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)); } }
Rule IDs must be unique - 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.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: Apache-2.0
- 更新时间: 2026-06-18