Testing Mago as an alternative to PHPStan

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=1G

Running a couple of times:

6.376s -- first run has no cache
0.261s -- cached results
0.277s
0.264s

Running again with one file updated each time:

1.088s
1.769s
2.210s

Installing 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 | bash

Checking that it works:

> mago --version
mago 1.0.2

Initialization

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 = true

Analyzing

time mago analyze

1.732s for the first run! That's a x3 improvement. I ran the same 5 more times:

1.731s
1.685s
1.714s

Mago 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 = true setting. 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` here

Cargo-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