offset-smoker / pest-ratchets
Architecture ratchets for Pest: rules that find offenders with a real PHP parser, and a committed baseline that may only shrink.
Requires
- php: ^8.3
- ext-json: *
- nikic/php-parser: ^5.7
- pestphp/pest: ^4.5 || ^5.0
Requires (Dev)
- laravel/pint: ^1.24
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Architecture ratchets for Pest. A rule finds the
code that breaks a convention: env() outside config/, a dd() left in,
raw SQL built from variables, a model without $fillable. Most codebases
already break their conventions somewhere, so a plain architecture test
either fails on day one or gets switched off. A ratchet records today's
offenders in a committed JSON baseline and holds the line from there: it
fails on any new offender, and it also fails when a baseline entry no
longer offends, so paid-off debt has to come off the list. The baseline
can only shrink. Rules read the code with a real PHP parser
(nikic/php-parser), not regular
expressions, so commented-out code and the contents of strings never count.
Requirements
- PHP 8.3 or later
- Pest 4.5 or later (Pest 5 needs PHP 8.4)
CI runs PHP 8.3 and 8.4, each with the newest and the lowest dependency
versions composer.json allows.
Installation
composer require --dev offset-smoker/pest-ratchets
The project has to run its tests with Pest already. A new Laravel app ships with PHPUnit; switch it to Pest first, as Pest's installation guide describes.
Pest discovers the package as a plugin through pestphp/pest-plugin,
which every Pest project already allows in config.allow-plugins. That is
what adds the --update-ratchets and --prune-ratchets options.
Usage
Create tests/Feature/RatchetsTest.php. The ratchets are tests, so the
file has to sit in a directory one of phpunit.xml's test suites loads; a
Laravel app's suites load only tests/Unit and tests/Feature. To keep
them in a directory of their own, such as tests/Arch, add
<testsuite name="Arch"><directory>tests/Arch</directory></testsuite> to
phpunit.xml first.
<?php use OffsetSmoker\PestRatchets\Rules\ModelsDeclareFillable; use OffsetSmoker\PestRatchets\Rules\NoDebugCalls; use OffsetSmoker\PestRatchets\Rules\NoDirectEnvCalls; use OffsetSmoker\PestRatchets\Rules\NoInterpolatedRawSql; ratchet(NoDirectEnvCalls::class)->in('app', 'routes')->baseline('tests/ratchets/env.json')->group('ratchets'); ratchet(NoDebugCalls::class)->in('app', 'routes', 'database')->baseline('tests/ratchets/debug.json')->group('ratchets'); ratchet(NoInterpolatedRawSql::class)->in('app')->baseline('tests/ratchets/raw-sql.json')->group('ratchets'); ratchet(ModelsDeclareFillable::class)->in('app/Models')->baseline('tests/ratchets/fillable.json')->group('ratchets');
ratchet() works like Pest's arch(): each call defines one test, named
after the rule (ratchet NoDirectEnvCalls). Paths are relative to the
project root.
Then record today's offenders, once, and commit the baselines:
vendor/bin/pest --update-ratchets git add tests/ratchets
Check that the ratchets ran:
vendor/bin/pest --group=ratchets
It should report 4 passed. If it reports no tests, the file is outside
every test suite in phpunit.xml, and nothing was checked.
From then on vendor/bin/pest runs the ratchets with everything else.
When a ratchet fails
A new offender fails with its file and line:
Ratchet NoDirectEnvCalls failed (baseline tests/ratchets/env.json).
New offenders. Fix them; the baseline may only shrink:
app/Services/Billing.php:14 env() called outside config/; read the value through config() instead
If the file already had baselined offenders of that kind, every one of
them is listed with a note such as (3 found in this file, the baseline allows 2; it cannot tell which of these are new), because the baseline
counts per file instead of storing lines.
Fixing an offender, or deleting the file, fails the other way:
Stale baseline entries. This debt is paid; remove it from the baseline:
app/Legacy/Old.php env() called outside config/; read the value through config() instead
(the baseline allows 1, 0 remain)
Run vendor/bin/pest --prune-ratchets to remove stale entries without adding any.
Updating a baseline
| Command | What it does |
|---|---|
vendor/bin/pest --prune-ratchets |
Lowers counts and removes entries that no longer offend. Never adds an entry or raises a count, so new offenders still fail. Safe to run any time. |
vendor/bin/pest --update-ratchets |
Rewrites each baseline to every current offender, then passes. This is how a baseline grows, so use it deliberately and review the diff. |
Both work with --filter and --parallel. Where you cannot add options
to the Pest command, set PEST_RATCHETS=prune or PEST_RATCHETS=update
instead. PEST_RATCHETS=update is refused when the CI environment
variable is set (to anything but false or 0), so a variable that leaks
into CI cannot turn every ratchet green; on CI, ask for an update by name
with --update-ratchets. The baseline is only rewritten when its content
changes.
The baseline is plain, sorted JSON, so a change to it reads clearly in review:
{
"rule": "OffsetSmoker\\PestRatchets\\Rules\\NoDirectEnvCalls",
"offenders": [
{
"file": "app/Legacy/Old.php",
"message": "env() called outside config/; read the value through config() instead",
"count": 1
}
]
}
Inside a test, or without Pest's runner
Ratchet is the same thing without the test definition:
use OffsetSmoker\PestRatchets\Ratchet; use OffsetSmoker\PestRatchets\Rules\NoDebugCalls; it('keeps debug calls out of the app', function () { Ratchet::rule(NoDebugCalls::class) ->in('app') ->baseline('tests/ratchets/debug.json') ->assert(); });
check() returns a RatchetResult (passed(), newOffenders,
staleEntries, report()) without asserting, and offenders() returns
what the rule found.
Rules
| Rule | Flags | Options |
|---|---|---|
NoDirectEnvCalls |
env(), Illuminate\Support\Env::get() and Env::getOrFail() outside config/. After config:cache, Laravel no longer loads .env, and these calls return their default (getOrFail() throws). |
new NoDirectEnvCalls(allowedPaths: ['config', 'bootstrap/app.php']) |
NoDebugCalls |
The dd(), dump() and ray() functions, and the ->dd(), ->dump(), ->ddRawSql(), ->dumpRawSql() and ->ray() methods on any object. |
new NoDebugCalls(functions: [...], methods: [...]); methods: [] checks functions only |
NoInterpolatedRawSql |
Raw SQL whose SQL argument is an interpolated string ("id = $id"), a concatenation with anything other than literals and constants, or a sprintf() of non-literal values. Checks DB::raw(), DB::select(), DB::selectOne(), DB::scalar(), DB::cursor(), DB::statement(), DB::unprepared(), DB::insert(), DB::update(), DB::delete() and DB::affectingStatement(), the same methods called directly on DB::connection(...), new Illuminate\Database\Query\Expression(...), and whereRaw(), orWhereRaw(), havingRaw(), orHavingRaw(), selectRaw(), orderByRaw(), groupByRaw() and fromRaw() called on a builder or statically on a model. |
new NoInterpolatedRawSql(facades: ['Illuminate\Support\Facades\DB', 'DB', 'App\Support\Db']) |
ModelsDeclareFillable |
Concrete models that declare neither a $fillable property nor Eloquent's #[Fillable] attribute, and any model, abstract ones included, that sets $guarded = [], #[Guarded([])] or #[Unguarded]. |
new ModelsDeclareFillable(modelClasses: [...]): the base classes whose direct subclasses count as models. The default is Eloquent's Model, Pivot, MorphPivot and Illuminate\Foundation\Auth\User; add your own base model. |
Pass an instance to ratchet() to use options:
ratchet(new NoDebugCalls(methods: []))->in('app').
To write your own rule, see docs/custom-rules.md.
Configuration
Everything is set on the ratchet itself:
| Method | Purpose |
|---|---|
in(string ...$paths) |
Directories (scanned recursively for .php files) or single files. Required. |
exclude(string ...$paths) |
Directories or files to skip. |
baseline(string $path) |
The baseline JSON file. Without one, any offender fails. |
root(string $path) |
The directory paths resolve against and baselines record paths from. ratchet() uses the project root; Ratchet::rule() uses the working directory. |
group(string ...$groups) |
(ratchet() only) Pest groups for the test, so --group=ratchets runs just these. |
Misconfiguration is an error, not a pass. A path that does not exist, a
path with no .php files in it, a path outside the root, a file (a
symlink, say) that resolves outside the root, a file PHP
cannot parse, paths whose every file is excluded, a missing baseline file
and a malformed baseline each stop the test with a message saying what is
wrong. A ratchet that silently scanned nothing would be worse than none.
Testing
composer install composer test # Pest composer analyse # PHPStan, level max, strict rules, no baseline composer lint # Pint
The suite runs the rules over fixture projects in tests/Fixtures. The
tests prove both failure directions, new offenders and stale entries,
that commented-out code and strings are never counted, and that code
moving within a file leaves the baseline alone. tests/Feature/EndToEndTest.php
also runs Pest itself in a separate process on a copy of a fixture project,
to show that ratchet() fails a real run and that --update-ratchets,
--prune-ratchets and PEST_RATCHETS reach the tests, in parallel runs too.
Status and limitations
Version 0.1.0. The four rules and the baseline format are tested, but the package has not yet been used on a large outside codebase, so expect false positives and gaps in the rules, and possibly changes to their messages (which would mean regenerating baselines).
- Syntax, not types. Rules read one file at a time with no type
information.
->whereRaw()and->dump()are matched by method name, whatever the object.ModelsDeclareFillableonly knows a class is a model when its ownextendsnames a configured base class; it cannot follow a chain of parents through other files. - What counts as interpolated SQL.
NoInterpolatedRawSqljudges the SQL expression it can see.whereRaw($sql)with a variable built elsewhere is not flagged, and neither are SQL strings built by helpers other thansprintf()andvsprintf(). A connection is only recognised as the call's direct receiver:DB::connection()->select(...)is checked, but$db = DB::connection(); $db->select(...)and a connection injected into a constructor are not. - Dynamic calls are not seen.
$fn = 'env'; $fn('X'),call_user_func('dd', $x)and the like pass every rule. - Unqualified calls inside a namespace (
env()innamespace App;) are treated as the global function, as PHP resolves them unless that namespace defines its own function of that name. - Declarations only.
ModelsDeclareFillablelooks for a$fillableproperty or#[Fillable], and for$guarded = [],#[Guarded([])]and#[Unguarded]on the class. It does not see$guardedor$fillableset in a constructor or afillable()call, orModel::unguard(). - Counts, not lines. The baseline counts each message per file. If you fix one offender and add another of the same kind in the same file, the ratchet cannot tell, and it passes.
in('.')scans everything,vendor/included. Name the directories you mean, or addexclude('vendor').- Not tested on Windows. Paths are normalized to forward slashes, but CI runs on Linux only.
- Pest's test impact analysis (Pest 5's
--tia) replays tests whose covered source has not changed. Ratchets read files rather than execute them, so run them without--tia, or in a separate full run in CI.
Security
See SECURITY.md. The package reads PHP files and the
baseline JSON, and writes only baseline files, and only when asked to
with --update-ratchets, --prune-ratchets or PEST_RATCHETS (which
refuses update on CI). Baseline entries are
validated: a file path must be relative, with no .. segments.
How this was built
Written with Claude Code under the direction and review of Brandon Wagner.
License
MIT. See LICENSE.