sandermuller / phpstan-to-mago
Transpile PHPStan rules into Mago analyzer plugins
Requires
- php: ^8.4
- nette/neon: ^3.4
- nikic/php-parser: ^5.7
Requires (Dev)
- carthage-software/mago: ^1.47.6
- hihaho/phpstan-rules: ^3.15.2
- laravel/pao: ^1.1
- laravel/pint: ^1.30
- mrpunyapal/rector-pest: ^0.2
- nunomaduro/collision: ^8.9
- orchestra/testbench: ^11.2
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- rector/rector: ^2.6
- sandermuller/boost-skills: ^2.36
- sandermuller/package-boost-php: ^1.0
- spaze/phpstan-disallowed-calls: ^4.14
- stolt/lean-package-validator: ^6.0
- symplify/phpstan-rules: ^14.12
- tomasvotruba/cognitive-complexity: ^1.2
- tomasvotruba/type-coverage: ^2.3
Suggests
- carthage-software/mago: Runs the generated analyzer plugins (installed as a dev dependency here so the runtime type-checks)
Provides
None
Conflicts
None
Replaces
None
README
You run Mago and you still run PHPStan, because your team's conventions exist only as PHPStan rules. This moves them: a rule's decisions usually reduce to guards over the syntax tree plus a few questions about the enclosing class, and that much becomes a Mago plugin. The rule object itself cannot travel — it reaches into classes Mago does not expose to PHP.
composer require --dev sandermuller/phpstan-to-mago vendor/bin/phpstan-to-mago --target=php --out=build src/Rules/ForbiddenStaticConstFetchRule.php vendor/bin/phpstan-to-mago --survey vendor/hihaho/phpstan-rules/src
| flag | effect |
|---|---|
--target=php|analyzer|linter |
a Mago plugin (default), or Rust for a fork of Mago itself |
--survey |
report what each rule would need, writing nothing |
--from-config=DIR |
the rules a project registers, not the ones its packages ship |
--help lists the rest. Each target writes its own subdirectory of --out, with a
generated/manifest.json naming each rule's identifier, messages and defaults.
Only the php target installs, emitting a worker plus the mago.toml snippet that registers it. The two
Rust targets emit source for Mago's bundled-plugin registry, which has no registration path from outside
Mago's own tree. Every count here is the php target — a rule can render as Rust and be refused as PHP.
What this is for
- Rules are the unit; a package is not. A package whose every rule emits can still be undroppable, because its neon registers extensions too.
- As a pre-filter: transpiled rules on save and push, full PHPStan on merge or nightly.
It does not make an existing PHPStan run cheaper: dropping rules does not drop the parsing and type inference underneath. And the pre-filter does not gate — a Mago-clean commit can still fail the deferred run, because a refused rule reports nothing, and so does one that under-reports.
Running a generated plugin
Mago runs PHP extensions as workers. Register the plugins in one, then point mago.toml at it:
<?php // worker.php declare(strict_types=1); use Mago\Sdk\Extension; use Mago\Sdk\Worker; use Transpiled\ForbiddenStaticConstFetchRule; require __DIR__ . '/vendor/autoload.php'; require __DIR__ . '/build/generated-php/ForbiddenStaticConstFetchRule.php'; (new Worker(new Extension( identifier: 'acme/transpiled', name: 'Transpiled PHPStan rules', version: '0.0.0', analyzerPlugins: [new ForbiddenStaticConstFetchRule()], )))->run();
[extension-hosts.transpiled] command = ["php", "worker.php"]
Generated plugins live in the Transpiled namespace and are on by default, so analyzer.plugins needs no
entry.
Configured rules
A rule taking constructor values gets them on the generated plugin, at the package's own defaults:
public function __construct( public readonly array $namespaces = ['App', 'Tests'], public readonly int $limit = 3, ) {}
Override in the worker, which manifest.json names. A rule taking a PHPStan service is refused — no
worker can supply one.
Refusals
A construct outside the vocabulary is refused, naming it and its line:
REFUSE ClosureUsesThisRule: no mapping for ->static on a hook-node (line 26)
Read them next to the emitted count, never alone.
What it can translate
Seven packages, pinned rule by rule in tests/Fixtures/expected/census.md and re-derived by a test.
| package | portable | emit | refused | covered by the engine |
|---|---|---|---|---|
symplify/phpstan-rules |
105 | 74 | 31 | 0 |
hihaho/phpstan-rules |
8 | 7 | 1 | 0 |
tomasvotruba/type-coverage |
5 | 5 | 0 | 0 |
tomasvotruba/cognitive-complexity |
3 | 2 | 1 | 0 |
phpstan/phpstan-strict-rules |
45 | 37 | 8 | 0 |
phpstan/phpstan-phpunit |
13 | 7 | 6 | 0 |
phpstan/phpstan-deprecation-rules |
2 | 2 | 0 | 0 |
--status counts 134 of 258 here and writes a page under --out. The denominator includes three more
installed packages that emit nothing. Run it on your own project.
What the vocabulary covers
Guard chains, foreach with an inline report, sprintf messages, instanceof narrowing, membership in a
constant set, comparisons on strings and integers, closures with their declared types, and a subtree search
with its count. Larger pieces:
- Helpers inlined from the rule, a trait or a parent class.
- The enclosing class: hierarchy, namespace, members with visibility, attributes and docblocks.
- Reflection at the use site, from Mago's codebase metadata.
- A producer handing a
{...}record to a consumer, including one produced inside a loop. - A collaborator that decides and builds the findings; only the reporting becomes a runtime pass.
- A collector-and-consumer pair, which becomes one whole-project pass with the measurement reimplemented, because Mago has no collector.
An aggregate is mapped only once its numbers agree with the real rule on a real project, and carries its
measured bound: run-coverage-corpus.php <project> --metric=<name>.
How far this is verified
Three things run, and each records rather than asserts:
| per rule | CI runs the real mago against real PHPStan over one example pair, comparing line and message. A rule that emits and reports nothing fails. |
| per divergence | a cause investigated is pinned as a minimal case, so it survives the corpus moving on — seven so far, five of which have since closed. The record goes red in either direction. The sweep's occurrences below are not individually mapped onto them. |
| per corpus | run-corpus-sweep.php reads seven trees this package installs, so composer install reproduces it: 11327 agreeing against 31 divergences, each listed. |
Size does not predict agreement: 1003 files of PHPUnit carry no divergence, while 367 files of Laravel's support and database trees carry 22 of the 31. VERIFICATION.md has every run, including the defects in this port the differential caught first.
Performance
php tests/Support/run-benchmark.php <project> runs both engines over your own code. Here, on
vendor/nikic/php-parser/lib — 270 files, 97 emitted rules, n=3, wall spreads 0.01–0.09s, on a machine that
was not otherwise idle. Mago's includes are the 12 package roots the emitted rules derive, 6614 files, which
is what --out writes into the snippet:
| wall | CPU | wall, all of vendor |
|
|---|---|---|---|
| mago, engine only | 1.00s | 1.13s | 3.91s |
| mago + a host with no plugins | 1.03s | 1.21s | 3.94s |
| mago + the 97 transpiled rules | 1.96s | 3.49s | 4.96s |
| PHPStan, cold result cache | 2.74s | 8.73s | |
| PHPStan, warm result cache | 1.03s | 0.92s |
The rules cost row three against row two, not against row one: a host that starts and speaks the protocol while registering nothing separates the host's own cost from the rules'. They add 0.93s wall and 2.28s CPU, and the host itself is free — rows one and two agree to 0.03s.
A mago figure without its include set means nothing, because mago indexes every file under includes on
every run. The last column is the same run pointed at all of vendor, 14822 files rather than 6614, for the
same 2686 findings. Those includes are what let a rule reach a vendored parent, and without them rules go
silently narrow rather than failing, so the lever is their width.
mago analyze has no result cache and redoes the whole job every run, which is why the warm PHPStan row is
the one to beat. Measure your own.
Requirements
PHP 8.4 for the transpiler, the floor the rule packages set. A generated plugin depends on this package and
on carthage-software/mago, and needs Mago 1.48.1 or later: that is where
FileAnalysisRequirement::VariableDefinedness arrived, and a plugin that asks whether a variable is defined
declares it. On 1.47.6 that constant does not exist, so such a plugin dies with a fatal the moment Mago
builds it — loudly, rather than answering wrongly.
1.47.6 was the previous floor for a quieter reason worth keeping: that is where a compound assignment's operands started reporting their own type, and a plugin reading one is silently wrong on anything earlier.
Contributing
composer qa-check runs the lot. Two invariants matter most, both in CLAUDE.md: the emitted output is the
contract, and anything the vocabulary does not cover is refused.
Release history is in CHANGELOG.md.
Credits
License
MIT. See LICENSE.