I discovered Mago, a PHP toolchain written in Rust, on Reddit a couple of days back. It recently published version 1.0. I am testing it primarily as a candidate to replace PHPStan.
PHPStan has been incredible in terms of the features it provide. But, itβs slow. Having experienced a near-instant DX with typescript, PHPStan's speed has always been a problem. In Hyvor Relay repo, which has about 3000 PHP source and test files, it takes about 5 seconds to re-analyze after a few changes. This makes development feedback loop slow that in most cases, we just write code, push it to CI, and finally fix the PHPStan issues later. Also, the IDE intergations are not great without a LSP.
I'm looking for two things in Mago:
It must support all the PHPStan features (PHPDoc types, generics, custom types, etc.)
It must be fast.
Note that Mago also has a linter and a formatter, which I will not be testing today.
Setup
I'll be testing Mago on Hyvor Relay (~3000 PHP files). It has a good amount of dependencies, uses generics and custom types, and is already configured with PHPStan.
My machine specs:
OS: Ubuntu 24.04.3 LTS x86_64
AMD Ryzen 7 7800X3D
Hyvor Relay runs inside Docker
Image: dunglas/frankenphp:1.10.0-php8.4.15
Composer: 2.8.8
PHPStan
Level: max
Paths: src, tests
PHPStan Performance
Before getting started, I'm using the time command to test PHPStan performance:
time vendor/bin/phpstan --memory-limit=1GRunning a couple of times:
6.376s -- first run has no cache0.261s -- cached results0.277s0.264sRunning again with one file updated each time:
1.088s1.769s2.210sInstalling Mago
It would be ideal if there was a Docker image where I could copy the mago binary from. For now, I simply did what the documentation says. I added the following to the Dockerfile.
RUN curl --proto '=https' --tlsv1.2 -sSf https://carthage.software/mago.sh | bashChecking that it works:
> mago --versionmago 1.0.2Initialization
Mago needs a mago.toml config to work with. I generated it using mago init (See Initialization in Mago docs)
This is the generated mago.toml file:
# Welcome to Mago! # For full documentation, see https://mago.carthage.software/tools/overview php-version = "8.4.0" [source] workspace = "." paths = ["src/", "tests/"] includes = ["vendor"] excludes = [] [formatter] print-width = 120 tab-width = 4 use-tabs = false [linter] integrations = ["symfony", "phpunit"] [linter.rules] ambiguous-function-call = { enabled = false } literal-named-argument = { enabled = false } halstead = { effort-threshold = 7000 } [analyzer] find-unused-definitions = true find-unused-expressions = false analyze-dead-code = false memoize-properties = true allow-possibly-undefined-array-keys = true check-throws = true check-missing-override = false find-unused-parameters = false strict-list-index-checks = false no-boolean-literal-comparison = false check-missing-type-hints = false register-super-globals = trueAnalyzing
time mago analyze1.732s for the first run! That's a x3 improvement. I ran the same 5 more times:
1.731s1.685s1.714sMago does not seem to support incremental analysis yet.
Static Analysis Results
Mago detected 1000+ errors, where PHPStan at max level showed 0.
~500 of those errors were from
check-throws = truesetting. This checks that all thrown exceptions are handled. There were many unhandled exceptions in the tests/ directory since many PHPUnit assertions throw them. For now, I disabled this check until I find a way to disable it only in the tests directory.Most of the other errors came from Zenstruck Foundry proxy objects. Hyvor Relay still uses
PersistentProxyObjectFactory, which is now deprecated.There were some errors that were better detected that PHPStan. For example:
$dnsIp = count($ips) > 0 ? $ips[0]->ip : ""; ^^^^^^^ This expression can be `null` hereCargo-like Error Reporting
My favorite so far is Cargo-like error reporting in Mago. They are so beautiful :)
warning[possibly-null-operand]: Left operand in `>` comparison might be `null` (type `bool|float|int|null|string`). ββ src/Service/Queue/QueueService.php:51:16 β 51 β return $this->em->createQueryBuilder() β βββββββββββββββββ^ 52 β β ->select('COUNT(q.id)') 53 β β ->from(Queue::class, 'q') 54 β β ->where('q.type = :type') 55 β β ->setParameter('type', QueueType::DEFAULT) 56 β β ->getQuery() 57 β β ->getSingleScalarResult() > 0; β β°βββββββββββββββββββββββββββββββββββββββββ^ This might be `null` β = If this operand is `null` at runtime, PHP's specific comparison rules for `null` with `>` will apply. = Help: Ensure this operand is non-null or that comparison with `null` is intended and handled safely.My Thoughts
First, I am genuinely impressed by the results. PHP ecosystem took many years to get to the stage where we have tools like PHPStan. Mago seems to just do it after a couple of years of development.
What I am most excited for is the upcoming LSP. This, along with the PHP extension installer, would dramatically change the PHP ecosystem. A nice LSP integration would make heavy IDEs less attactive.
We will be integrating Mago into our workflows and see how it performs in real-world scenarios.
Comments