Mago: Blazing Fast Static Analysis for PHP
Video version: https://youtu.be/ZtAKnN6jWbE
I love static analysis tools to a degree that my co-workers find annoying. Tools like PHPStan and Psalm catch bugs before they ever reach production, and once you get used to that safety net, it is impossible to imagine coding without it. But there is one thing about these tools that drives me a little crazy: they are “slow”.
On a large codebase, a full analysis can take minutes gigabytes of memory. I have split my analysis into chunks and run them in parallel just to get my build times back down to something reasonable. While that solution works, it’s always felt like a hack to me.
Today we are going to look at a potentially better way. It’s called Mago (mah-go), and it is a static analysis tool for PHP written in Rust rather than PHP, which makes it shockingly fast. We will cover how to install it, how to lint your code, how to analyze it for bugs, and how to format it.
What Is Mago?
Mago(mah-go) is a static analysis toolchain for PHP, but unlike other static analysis tools, it is not written in PHP. It is written in Rust. Because it is Rust, it compiles down to a single native binary. You do not need a PHP runtime to run it, you do not need Composer to install it, and there is no bootstrapping overhead every time you kick it off. You just run the binary, and it goes.
Installing Mago
The quickest way to install Mago is with the official installer script (check to see if this has changed too):
curl --proto '=https' --tlsv1.2 -sSf https://carthage.software/mago.sh | bash
That downloads the right binary for your operating system and drops it somewhere on your path. If you prefer a different approach, Mago is also available through the normal Composer install, but there are also Homebrew, Docker, and, I think for the first time I’ve seen it, WinGet, so pick whichever fits your workflow best. On a Mac, brew install mago is about as painless as it gets.
Once it is installed, confirm it worked:
mago --version
mago 1.43.0
There’s also setup-mago GitHub Action that will let it run in your actions.
Setting Up Your Config
The first step is to create a config using:
mago init
This guides you through the process of creating a mago.toml file in the root of your project. If you have used PHPStan or Psalm, think of mago.toml as the equivalent of phpstan.neon or psalm.xml. It is where you tell Mago which directories to scan, what PHP version to target, and how strict you want the rules to be. The .toml format is clean and readable, so even if you have never seen it before, you will figure it out quickly. There are a ton of options but try to keep it simple to start and then add on more options.
Linting Your Code
The mago lint command checks your code against rules covering correctness, consistency, and clarity:
mago lint
Say you have a function like the following:
<?php
function getDiscount(User $user): float
{
if ($user->isActive()) {
if ($user->isPremium()) {
return 0.25;
}
}
return 0.0;
}
You’ll get something like the following:
./mago lint test.php
warning[strict-types]: Missing `declare(strict_types=1);` statement at the beginning of the file.
┌─ test.php:1:1
│
1 │ <?php
│ ^^^^^
│
= The `strict_types` directive enforces strict type checking, which can prevent subtle bugs.
= Help: Add `declare(strict_types=1);` at the top of your file.
help[function-name]: Function name `getDiscount` should be in snake case.
┌─ test.php:3:10
│
3 │ ╭ function getDiscount($user): float
│ ^^^^^^^^^^^ Function `getDiscount` is declared here
4 │ │ {
5 │ │ if ($user->isActive()) {
6 │ │ if ($user->isPremium()) {
· │
11 │ │ return 0.0;
12 │ │ }
│ ╰─' Function `getDiscount` is defined here
│
= The function name `getDiscount` does not follow snake naming convention.
= Help: Consider renaming it to `get_discount` to adhere to the naming convention.
warning: found 2 issues: 1 warning(s), 1 help message(s)
= 1 issues contain auto-fix suggestions
Because this is a safe auto-fix, you can let Mago rewrite it for you:
./mago lint --fix test.php
WARN Skipped 1 potentially unsafe fixes. Use `--potentially-unsafe` or `--unsafe` to apply them.
INFO No fixes were applied.
./mago lint --fix --unsafe test.php
INFO Successfully applied 1 fixes.
And now our file has declare(strict_types=1) at the top:
<?php
declare(strict_types=1);
More type safety and you didn’t have to touch it by hand.
We’ll have more after this word from our partners.
Analyzing for Bugs
While linting is about style and clarity, and analysis is about catching actual bugs. I like this description that Mago has on their website:
The analyzer builds a semantic model of your entire codebase. It knows what types functions return, what properties classes have, and what can throw. It finds logical impossibilities like calling a method that does not exist on the type at hand.
That is what mago analyze does:
mago analyze
It hunts for problems like type mismatches, dead code, and logic that cannot possibly work.
Here is a quick example. This function promises to return an int but returns a string:
<?php
function totalItems(int $cartCount): int
{
return $cartCount . " items";
}
Running the analyzer catches it right away:
./mago analyze test.php
error[invalid-return-statement]: Invalid return type for function `totalItems`: expected `int`, but found `truthy-lowercase-string`.
┌─ test.php:8:12
│
8 │ return $cartCount . " items";
│ ^^^^^^^^^^^^^^^^^^^^^ This has type `truthy-lowercase-string`
│
= The type `truthy-lowercase-string` returned here is not compatible with the declared return type `int`.
= Help: Change the return value to match `int`, or update the function's return type declaration.
error: found 1 issues: 1 error(s)
That is the kind of bug that would, and will, sit quietly until a customer hits it on the busiest day of the year.
Formatting Your Code
Mago also allows us to force a formatting option on our code. The mago fmt command applies deterministic, consistent formatting to your whole codebase:
./mago fmt test.php
INFO Formatted 1 file(s) successfully.
Run it once, and everyone on your team gets the exact same style with zero arguments about spacing or brace placement.
About That Speed
In order to do what mago is doing with a single command, I have to run 2 other tools, and for some reason it’s finding more issues than I have found in other tools (I’m guessing due to differences in where errors are categorized). In my tests, it takes Mago ~3.4s vs ~13.9s (8.2s + 5.7s) on my ~134k line side project. That’s a large difference, especially given that I run these tools dozens of times a day, which will really add up.
Fitting It Into Your Workflow
I’m a huge fan of running tools like Mago in my CI/CD pipelines and my pre-commit scripts. I’m currently testing it as a replacement for PHPstan and PHP_CodeSniffer in my stack, and I’m excited to see the results coming back faster, but there are some gaps in its implementation I can’t get around that the more mature tools have. Right now I’m using it as a “yes and” stage in my testing so I can get faster feedback but it doesn’t look like a direct replacement just yet.
Gotchas
A few things to keep in mind. First, Mago is still maturing. It is fast and capable, but not every single PHPStan or Psalm rule has an equivalent yet, so you may hit a check you are used to that is not there.
Second, mago fmt reformats your entire codebase in place. Commit your work first so if the formatting does something you did not expect, you can see exactly what changed in your diff and roll it back cleanly.
Finally, remember that Mago reads its settings from mago.toml. It does not look at your existing phpstan.neon or psalm.xml, so you will need to configure it fresh.
What You Need To Know
- Mago is a static analysis toolchain for PHP written in Rust
mago lintchecks correctness, consistency, and claritymago analyzecatches type errors and dead codemago fmthandles formatting.- It’s not as mature as other tools in the ecosystem, but hopefully this will change soon.



Leave a comment
Use the form below to leave a comment: