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. It emits a worker plus the mago.toml snippet registering it, against
Mago's supported plugin API. The two Rust targets emit source for Mago's own bundled-plugin registry, which
has no registration path from outside Mago's tree. Every count here is the php target — a rule can render
as Rust and be refused as PHP.
A plugin depends on this package and on carthage-software/mago:
final class ForbiddenStaticConstFetchRule implements Plugin, NodeAnalysisHook { public function getTargets(): array { return [NodeKind::ClassConstantAccess]; } public function analyze(NodeAnalysisContext $context): void { $node = $context->node; if (!(Support::isName(Support::classPart($context, $node)))) { return; } // ... } }
What this is for
- Rules are the unit; a package is not.
phpstan/phpstan-deprecation-rulesis the first with both its rules emitting, and is still not droppable: its neon also registers fiveRestricted*UsageExtensionservices, where "call to deprecated method" comes from, and none of those is a rule. - 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 |
89 | 69 | 19 | 1 |
hihaho/phpstan-rules |
8 | 7 | 1 | 0 |
tomasvotruba/type-coverage |
10 | 5 | 5 | 0 |
tomasvotruba/cognitive-complexity |
3 | 2 | 1 | 0 |
phpstan/phpstan-strict-rules |
45 | 32 | 13 | 0 |
phpstan/phpstan-phpunit |
13 | 5 | 8 | 0 |
phpstan/phpstan-deprecation-rules |
2 | 2 | 0 | 0 |
--status counts 122 of 236 here and writes a page under --out; spaze/phpstan-disallowed-calls (38),
composer/pcre (2) and larastan/larastan (26) are in that denominator and 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 —
Mago has no collector. Five of
type-coverage's metrics are mapped this way.
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 | each one found is pinned as a minimal case, so it survives the corpus moving on. The record goes red in either direction. |
| 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 is not what buys agreement: the two smallest trees carry most of the divergences and 1003 files of PHPUnit carry none. 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, 80 emitted rules, against PHPStan 2.2.13, best of six:
| wall | CPU | |
|---|---|---|
| mago, engine only | 4.00s | 3.87s |
| mago + the 80 transpiled rules it had then | 6.03s | 7.21s |
| PHPStan, cold result cache | 2.74s | 8.64s |
| PHPStan, warm result cache | 0.89s | 0.87s |
Read the CPU column: this ran at a load average near 5, so the wall figures are pessimistic and unequally so. All four rows come from the same two runs, on mago 1.47.5; the two mago rows reproduce within 2% on the 1.47.6 this package requires. The rules add 3.34s CPU — the marginal cost, which no total gives.
Not a speed win on this corpus: 1.20x cheaper than a cold PHPStan on CPU, dearer than a warm one,
because mago analyze has no result cache. Measure your own.
Requirements
PHP 8.4 for the transpiler, the floor the rule packages set. Generated plugins need Mago 1.47.6 or later: 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.
Credits
License
MIT. See LICENSE.