carthage-software/mago
Mago is an extremely fast PHP linter, formatter, and static analyzer written in Rust. It brings speed, reliability, and a great developer experience to PHP projects of all sizes.
Mago is a single Rust binary that gives you linting, formatting, and static analysis without booting PHP or loading extensions. Three steps to first value on an existing Laravel app:
Install as a dev dependency (preferred for teams — the version lands in composer.lock and every teammate/CI runner gets the same binary for their platform):
composer require --dev carthage-software/mago
The standalone installer (curl ... https://carthage.software/mago.sh | bash) and Homebrew are fine for individual machines, but a pinned Composer dependency is what keeps CI reproducible.
Generate config: vendor/bin/mago init writes a mago.toml at the project root. Commit it immediately — this file is your policy, and every subsequent decision (strictness, ignores, exclusions) lives there rather than in CLI flags.
First use case — measure before you change anything. Run the three tools read-only against a slice of the codebase:
vendor/bin/mago lint app/Models
vendor/bin/mago format --dry-run app/Models
vendor/bin/mago analyze app/Models
Where to look first: the generated mago.toml (it documents the sections you'll actually touch), then the docs' configuration reference for [linter], [linter.rules], [formatter], and [analyzer]. Read the rule list once — the analyzer and linter both ship a lot of defaults, and knowing which tool owns which check saves you from chasing a rule that moved.
Composer scripts as the team interface. Don't let people memorize flags; give them verbs:
"scripts": {
"lint": "mago lint",
"lint:fix": "mago lint --fix",
"format": "mago format",
"analyze": "mago analyze",
"check": ["@lint", "@analyze", "mago format --dry-run"]
}
composer check becomes the single pre-PR command, and CI calls the same thing.
Adoption order: format → lint → analyze. These have very different noise profiles.
.git-blame-ignore-revs, done. This is also where you decide Mago replaces Pint/PHP-CS-Fixer rather than running alongside it (see Gotchas).--fix to auto-correct the safe subset, then a manual pass on the rest. Use --only with comma-separated rules to burn down one rule at a time across the whole repo instead of fixing everything per-file.app/, get to zero, then widen to tests/ and database/.Path-scope your exceptions instead of disabling rules. Both tools support scoping, which is what makes gradual adoption survivable:
[analyzer]
ignore = [
"mixed-argument",
{ code = "missing-return-type", in = "tests/" },
{ code = "unused-parameter", in = ["tests/", "app/Generated/"] },
]
[linter.rules]
no-isset = { exclude = ["app/Legacy/"] }
Exclude non-application paths globally — vendor/, storage/, bootstrap/cache/, resources/views/ (Blade isn't plain PHP and will produce parse noise), and any generated code directory.
Editor integration. Run mago lsp and point your editor's PHP language server at it for in-editor diagnostics and formatting. This is where the "extremely fast" claim earns its keep — the feedback loop is fast enough to leave on during a full test run.
Pre-commit hook on staged files only. A hook that runs mago lint and mago format against the changed files keeps commits clean without adding a multi-second tax to every commit; leave the full-tree run to CI.
CI job shape. One job, no PHP setup beyond Composer:
- run: composer install --prefer-dist --no-interaction
- run: composer check
The binary is cached by Composer's cache, and Mago parallelizes across cores by default — you generally don't need to shard it the way you'd shard PHPStan.
Debugging a suspicious diagnostic with mago ast. When the analyzer or formatter says something that looks wrong, dump the AST for the file before filing an issue. Half the time it reveals a docblock that isn't being parsed the way you assumed; the other half you have a genuinely useful bug report with a minimal repro.
Pin the exact version, and treat upgrades as dependency bumps. This project releases frequently and defaults change between minors — 1.6.0 enabled no-isset and readable-literal by default, flipped inline-empty-function-braces to match PER-CS, and removed five linter rules whose functionality moved to the analyzer. A caret constraint means a Tuesday composer update can turn a green pipeline red for reasons unrelated to your code. Bump deliberately, run composer check locally, fix in the same PR.
Rule names from older blog posts may no longer exist. Anything type-related that used to be a linter rule (parameter-type, return-type, property-type, constant-type, no-boolean-literal-comparison) now lives in the analyzer. If a config key is silently ignored, check whether it migrated sections.
First analyzer run on a real app is noisy — that's expected, not broken. Recent versions correctly report non-existent classes in @var docblocks and on the right-hand side of instanceof, which surfaces a lot of pre-existing docblock rot. Use a committed baseline file so existing violations are grandfathered and only new ones fail CI, then shrink the baseline over time. Never hand-edit the baseline; regenerate it after big refactors. (Confirm the exact generation flag in the analyzer docs — it's the one CLI detail worth verifying against your installed version.)
Expect narrowing-related false positives if you lean on value objects. The analyzer clears property type narrowings after calls that might mutate an object. Annotate genuinely immutable methods with @pure, @mutation-free, or @external-mutation-free (Psalm's @psalm-mutation-free is also recognized) so it stops invalidating. If you see spurious warnings specifically around DateTimeImmutable/DateTimeZone, you're on a pre-1.9.1 version — upgrade.
Don't run two formatters. Mago and Pint/PHP-CS-Fixer will fight over the same lines forever, and every save undoes the other's work. Pick one. If your team is mid-migration, scope Mago to lint + analyze and leave formatting to the incumbent until you cut over in a single dedicated commit.
Production installs skip it. As a require-dev dependency, composer install --no-dev won't fetch the binary. Any deploy script that calls mago must guard on the binary existing, or run the check in the build stage of your Dockerfile where dev dependencies are still installed.
--dry-run in CI, write in the hook. Formatting that writes during CI produces diffs nobody looks at. Fail the build with mago format --dry-run and let developers fix locally.
Inline suppression is for one-offs, config is for patterns. Reach for a suppression comment when a single line is legitimately wrong-looking; reach for ignore = [{ code = ..., in = ... }] when a whole directory has a reason to differ. If you find yourself sprinkling comments across the same directory, move it to config — otherwise the next person to touch that code has no idea the exception is intentional.
Watch the cache directory. Mago caches analysis work on disk for speed; make sure whatever directory it creates is in .gitignore and that CI doesn't restore a stale cache across incompatible versions.
How can I help you explore Laravel packages today?