mltstephane / laravel-quality
Shared static analysis and architecture rules for Laravel projects: Larastan base config, Pest arch preset and a quality:install command.
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- larastan/larastan: ^3.14
- pestphp/pest: ^4.0|^5.0
- phpstan/phpstan-deprecation-rules: ^2.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Shared static analysis and architecture rules for my Laravel projects: deterministic checks that AI agents (and humans) run instead of prose rules.
What it ships
extension.neon: base PHPStan parameters (level 6, unmatched or uncommented ignores fail,parseModelCastsMethodso Larastan reads the casts declared inModel::casts()) and the package's own rules (below).laravelQualityPest arch preset: noDBfacade in Livewire or HTTP, models/actions/services independent from the presentation layer, queued jobs, no debug calls (dd,dump,ray,exit…), and optionally a test per action.php artisan quality:install: publishesphpstan.neon.dist, an emptyphpstan-baseline.neon,pint.json(Laravel preset withdeclare_strict_typesandfully_qualified_strict_types; skipped when the project has aduster.json, since Duster runs Pint with its own config),tests/Architectures/QualityTest.php, adds thestan,stan:baseline,archandcheckcomposer scripts and theArchitecturetest suite. It never overwrites an existing file or script.--cialso publishes.github/workflows/quality.yml: PHP fromcomposer.json(config.platform.php, else the lowest version ofrequire.php),composer install,key:generate,pint --test,composer stan, theArchitecturesuite, gitleaks (Docker image,detecton the whole history) and a check that no file matched by.gitignoreis tracked (git ls-files -ci --exclude-standard).--hooksalso publisheslefthook.yml: on pre-commit,pint --dirty,gitleaks protect --staged,composer stan. Install lefthook and gitleaks, then runlefthook install.
php artisan quality:check-config: fails when the project'sphpstan.neon(orphpstan.neon.dist) sets a level below 6 or does not include the shared extension.quality:installprints the same warnings.- Pinned versions of Larastan, PHPStan deprecation rules and Pest.
Project-specific rules (business namespaces, ignoring(), baseline) stay in each project.
Requirements
PHP 8.3+, Laravel 12 or 13, Pest 4 or 5 (Pest 5 needs PHP 8.4+).
Install
composer require --dev mltstephane/laravel-quality
php artisan quality:install
composer stan:baseline # existing projects: freeze current errors
composer check
CI: run composer stan after the app key is generated, and make sure the Architecture suite runs, or publish the workflow with php artisan quality:install --ci.
Before the package is on Packagist, declare the repository first:
composer config repositories.laravel-quality vcs https://github.com/MltStephane/laravel-quality
An existing phpstan.neon(.dist) is completed, not replaced: missing includes (Larastan and deprecation rules unless phpstan/extension-installer loads them, the shared extension, then the baseline last) are added, and the parameters the shared extension provides are removed when they hold the same value. A different value, such as a higher level, stays and overrides the shared one. Composer does not run a dependency's scripts: run quality:install once after composer require; it is safe to rerun.
PHPStan rules
| Rule | Default | Identifier | Reports |
|---|---|---|---|
NoModelWriteInPresentationRule |
on | laravelQuality.modelWriteInPresentation |
In the presentation namespaces: create, forceCreate, firstOrCreate, updateOrCreate, insert, upsert, destroy, save, update, delete, forceDelete on a model, an Eloquent builder or a relation. Move them into an action. |
NoQueryInPresentationRule |
off | laravelQuality.queryInPresentation |
In the presentation namespaces: a static query entry point on a model class: ::query(), ::all(), ::find(), ::findOr(), ::findOrFail(), ::findMany(), ::first(), ::firstOrFail(), ::firstWhere(), ::sole(), ::get(), ::value(), ::pluck(), ::count(), ::exists(), ::paginate(), ::simplePaginate(), ::cursorPaginate(), ::cursor(), ::with(), ::withCount(), ::latest(), ::oldest(), ::orderBy(), ::where*(). The calls chained after them and the reads through a relation of a loaded model ($user->posts()->get()) are not reported. |
NoStringRoleRule |
on | laravelQuality.stringRole |
A string literal, or an array holding one, as first argument of hasRole, hasAnyRole, assignRole. Use an enum: Role::Admin->value. |
NoBroadCatchWithoutRethrowRule |
on | laravelQuality.broadCatchWithoutRethrow |
catch (Throwable) or catch (Exception) that is empty or does not rethrow. report() alone is reported; a throw inside a closure does not count. |
MissingLockedIdPropertyRule |
off | laravelQuality.missingLockedId |
Public property of a Livewire component named id or *Id, typed int, string or both (nullable or not), without #[Locked]. Properties carrying #[Validate], #[Url] or #[Modelable] are skipped. |
The presentation namespaces are App\Livewire, App\Http and App\NativeComponents (NativePHP mobile components, which also write to the database).
Each rule can be turned on or off, and the presentation namespaces changed, in the project's phpstan.neon.dist:
parameters: laravelQuality: noModelWriteInPresentation: true noQueryInPresentation: true noStringRole: false noBroadCatchWithoutRethrow: true missingLockedIdProperty: true # Lists merge with the shared values: `!` replaces them. presentationNamespaces!: - App\Livewire - App\Http\Controllers
Why two rules are off by default:
MissingLockedIdPropertyRule: on 11 projects, 12 of its 26 reports were false positives. An identifier bound bywire:model(a patient picker, a filter, a restaurant choice) is meant to be written by the browser, and#[Locked]would break it; PHPStan does not read the Blade views, so it cannot tell such a property from an identifier the component sets itself. The rule skips the properties that declare themselves as inputs (#[Validate],#[Url],#[Modelable]), which removes part of the noise without reading the views. Turn it on to audit a project, and treat each report as a question: a bound identifier must then be checked against the user's rights where it is used (authorize(), a scoped query).NoQueryInPresentationRule: reading models inrender()or in a controller is the documented Laravel and Livewire way, and wrapping each read in an action mostly produces one-line classes. On one project, reads were 23 of the 30 reports of the 1.2.0 write rule: they buried the writes, which are the real risk (validation, authorization and side effects bypassed), and swelled the baselines. Turn it on in a project that routes reads through query classes. Writes stay on by default.
The broad catch rule lives in this package rather than coming from shipmonk/phpstan-rules: shipmonk has no rule for a broad catch that swallows the error (its requirePreviousExceptionPass only checks the exceptions that are rethrown), and it enables all its rules by default, so adding it to every project would bring dozens of unrelated errors to triage for one check.
Use the preset
use MltStephane\LaravelQuality\Pest\ArchPreset; ArchPreset::register(); arch()->preset()->laravelQuality()->ignoring(['App\Services\LegacyService']);
ArchPreset::register(requireActionTests: true) also requires a tests/**/{Action}Test.php for each class under App\Actions (subnamespaces included; the test is matched by class name). It is off by default, and ignoring() does not exempt an action from it.
Upgrade
A release that enables a stricter or more precise rule changes the errors PHPStan reports: entries in an existing baseline may no longer match (reportUnmatchedIgnoredErrors then fails). Regenerate it with composer stan:baseline after updating.
1.2.0 → 1.3.0
MissingLockedIdPropertyRuleis now off by default (missingLockedIdProperty: false), and skips the properties carrying#[Validate],#[Url]or#[Modelable]. SetmissingLockedIdProperty: trueto keep it.NoModelWriteInPresentationRuleno longer reports::query(): reads moved to the newNoQueryInPresentationRule(laravelQuality.queryInPresentation), off by default. SetnoQueryInPresentation: trueto keep reporting them. The write errors keep their message and identifier: their baseline entries still match.App\NativeComponentsjoins the presentation namespaces: the writes made there are now reported.quality:installno longer publishespint.jsonwhen the project has aduster.json. Apint.jsonpublished by 1.2.0 next to aduster.jsonis not removed: delete it if Duster should keep its own config.- Regenerate the baseline with
composer stan:baseline: the entries of thelaravelQuality.missingLockedIderrors and thequery()entries oflaravelQuality.modelWriteInPresentationno longer match anything, andreportUnmatchedIgnoredErrorsfailscomposer stanuntil they are removed. Run it after choosing which opt-in rules to enable.
To 1.2
- The four PHPStan rules of 1.2.0 are on by default: run
composer stan:baselineto freeze the existing errors, or turn a rule off inphpstan.neon.dist. quality:installnow publishespint.jsonwhen the project has none. Itsdeclare_strict_typesrule addsdeclare(strict_types=1);to every PHP file on the nextpintrun: review that diff on its own (a call that relied on type coercion now throws aTypeError), and regenerate the baseline afterwards.- Run
php artisan quality:check-configin CI to catch a level lowered below 6 or a removed include.
Develop
composer install
composer test