PHP Architect logo

Want to check out an issue? Sign up to receive a special offer.

Stop Repeating Yourself in Code Review: Build a Custom Mago Lint Rule

Posted by on August 29, 2026

Video version: https://youtu.be/Sns4iMSa3qA

Every team I have ever worked on has a rule that lives in someone’s head instead of in a tool. An example of this is that if you’re working with a Laravel project, you should use Illuminate\Support\Carbon and not Carbon\Carbon directly. We all knew it, but we still shipped the wrong import because a human has to remember to catch it. Mago is an amazing way to catch these types of issues, but it doesn’t have this by default because it was framework-dependent and we couldn’t write our own. Thankfully, in Mago 1.47 we can now add custom rules to catch “house rules”.

In this article, we will build our own Mago lint rule as an extension, and we will build it test-first so we trust it before it ever prevents our CI/CD pipeline from passing. The rule will flag use Carbon\Carbon; and nudge the developer toward use Illuminate\Support\Carbon; instead, which is the same fix the Laravel Shift “Use Laravel Carbon” applies, except this runs for your whole team on every commit.

If you watched our earlier article on Mago as a fast static analyzer, this picks up right where that left off, so I will not re-explain what Mago is or how to install it here.

If you’re new here, we cover topics related to the PHP ecosystem. Hit subscribe so you don’t miss the next one.

The Pieces of an Extension

Mago extensions are written in PHP using Mago’s SDK. At the time of this recording, this is a brand new feature, so you need Mago 1.47 or newer, a Composer project with the carthage-software/mago dependency, and optionally mago.toml (tom-el) in the project root.

The Composer package is important because you need it for the Mago\Sdk classes, and it installs vendor/bin/mago so we can more easily use Mago.

composer require --dev carthage-software/mago

An extension has three components:

  1. A Rule that implements the Mago\Sdk\Linter\Rule interface.
  2. An Extension factory with a static create() method that returns an Extension.
  3. A Worker entrypoint, which is a small PHP script that runs the protocol.

The Rule interface requires you to implement two methods: getDefinition(), which returns a RuleDefinition with metadata like the code, name, description, default level, and targets, and lint(), which receives a LintContext and holds your logic.

It’s important to note the targets parameter in the RuleDefinition. This parameter tells Mago what matching “nodes” we’re interested in and sends only the syntax subtrees you asked for to your rule. This prevents you from having to walk the whole AST, which is where at least some of the speed comes from.

The other important note is that rather than making people register your rules by hand, consumers call your factory:

public static function create(): Extension
{
    return new Extension(
        identifier: "unleashedpodcasts/project-rules",
        name: "UnleashedPodcasts project rules",
        version: "1.0.0",
        linterRules: [new UseLaravelCarbonRule()],
    );
}

Starting our tests

Before we write a failing test, we need to be honest about what you can and cannot unit test here, because it’s a little challenging to understand.

You cannot easily hand-build a LintContext in PHP. Its SourceFile takes node stores that Mago populates from the Rust side, and that constructor is marked @internal. So there is no clean way to say “run lint() over this string of PHP and hand me back the diagnostics.”

Because of this, we end up with a split of tests.

Corpus tests

Corpus tests are your behavior tests. These are “actual” PHP files carrying @mago-expect annotations and are linted by a real Mago run. This is where “does the rule fire, and does it stay quiet when it should” gets answered.

PHPUnit tests

PHPUnit tests cover registration and metadata like the identifier, the version, the rule’s code and level, and targets. This is actually something you could ignore if you were using TDD like a good developer.

If you’re used to just PHPUnit-based tests, this split may feel wrong, but it’s the best option because we need Mago’s Rust-based logic to test that what we’ve created will correctly get flagged in Mago’s engine.

Write the Failing Test First

We’re going to start with the corpus workspace. You’ll create “tests/mago-corpus/mago.toml” that registers a worker you have not written:

version = "1"
php-version = "8.4"

[source]
paths = ["src"]

[extension-hosts.unleashedpodcasts]
command = ["php", "../../.mago/worker.php"]
workers = 1

Now we’re going to create our example at “tests/mago-corpus/src/CarbonImports.php”, annotated with “@mago-expect” lines to say you expect your rule to fire on the line where the problem exists:

<?php

namespace Corpus;

// @mago-expect lint:unleashedpodcasts/use-laravel-carbon
use Carbon\Carbon;

Run it:

mago --workspace tests/mago-corpus lint --only unleashedpodcasts/use-laravel-carbon
ERROR Orchestrator error: External linter error: external linter worker
failed: extension worker 0 disconnected: worker closed stdout;
stderr: Could not open input file: ../../.mago/worker.php

Add the PHPUnit half too, asserting a definition that does not exist yet:

public function testRuleDefinition(): void
{
    $definition = (new UseLaravelCarbonRule())->getDefinition();

    self::assertSame("unleashedpodcasts/use-laravel-carbon", $definition->code);
    self::assertSame(Level::Warning, $definition->defaultLevel);
    self::assertSame([NodeKind::UseItem], $definition->targets);
}
PHPUnit 11.5.0

E                                                                   1 / 1 (100%)

Error: Class "App\Mago\Rules\UseLaravelCarbonRule" not found

FAILURES!
Tests: 1, Assertions: 0, Errors: 1.

Both failures are the point. Now we have something to make green.

Green: Implement Just Enough

Create the rule and give it a definition that targets import statements. The node kind you want is NodeKind::UseItem, which is one item inside a use statement, alias and all:

public function getDefinition(): RuleDefinition
{
    return new RuleDefinition(
        code: "unleashedpodcasts/use-laravel-carbon",
        name: "Use Laravel Carbon",
        description: "Prefer Illuminate\\Support\\Carbon over Carbon\\Carbon.",
        defaultLevel: Level::Warning,
        defaultEnabled: true,
        targets: [NodeKind::UseItem],
    );
}

Then lint() inspects each import the Mago hands you. You don’t need to read raw text, and you don’t need to match on the short name. You can use LintContext::getResolvedName() to return a ResolvedName carrying the fully-qualified name and, just as usefully, the span that name occupies:

public function lint(LintContext $context): void
{
    $context->cancellation->throwIfCancelled();

    $resolved = $context->getResolvedName();
    if ($resolved === null || strcasecmp($resolved->name, "Carbon\\Carbon") !== 0) {
        return;
    }

    $context->report(
        Issue::new(
            "Import Illuminate\\Support\\Carbon instead of Carbon\\Carbon.",
            $context->node->span,
        )
            ->withHelp("Illuminate's Carbon subclass carries Laravel's macros and testing helpers.")
            ->withEdit(TextEdit::replace($resolved->span, "Illuminate\\Support\\Carbon")),
    );
}

Three things in there are worth highlighting.

$context->report() takes an Issue, not a string. Issue::new() wants a message and a span, and everything else hangs off fluent methods like withHelp(), withNote(), and withSecondaryAnnotation().

throwIfCancelled() is how you cooperate when Mago decides to stop early.

Finally, withEdit() is what turns your rule into something that mago lint --fix can fix on its own. Notice it replaces $resolved->span, the span of the name, and not the span of the whole node. That is deliberate so use Carbon\Carbon as LegacyCarbon; keeps its alias instead of getting stomped.

If you run the suite again, the earlier failing tests will go green.

We’ll have more after this word from our partners.

Refactor: Prove No False Positives

A rule that fires is only half the job. A rule that fires when it should stay quiet is worse than no rule, because it trains your team to ignore warnings. Add “good” imports to the fixture, with no annotation on them:

// @mago-expect lint:unleashedpodcasts/use-laravel-carbon
use Carbon\Carbon;
// @mago-expect lint:unleashedpodcasts/use-laravel-carbon
use Carbon\Carbon as LegacyCarbon;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon as SupportedCarbon;

CarbonImmutable is the one that catches the classic beginner mistake, because a sloppy str_contains($name, "Carbon") flags it and a correct comparison does not.

It’s important to note that a corpus test run that reports nothing is not proof your rule works. If your targets are wrong and lint() never fires, you get the same “No issues found” you get when everything is correct.

To fix this, you’re going to test the test. Delete one @mago-expect line and confirm the diagnostic shows up:

mago --workspace tests/mago-corpus lint --only unleashedpodcasts/use-laravel-carbon --reporting-format count
warning: 1

Then put it back, add a bogus expectation on a line that should be clean, and confirm Mago complains about the unfulfilled expectation:

mago --workspace tests/mago-corpus lint --only unleashedpodcasts/use-laravel-carbon --reporting-format count
warning: 1

Only after that check did I believe the green run.

Now wire the worker up. The entry point reserves standard output for the SDK protocol, so any logging you do goes to standard error:

use Mago\Sdk\Worker;

require dirname(__DIR__) . "/vendor/autoload.php";

(new Worker(UnleashedPodcastsExtension::create()))->run();

Register it in mago.toml. While you’re in there, exclude the corpus from your root [source], because your fixtures are deliberately-broken PHP and you do not want your real lint run — or --fix — touching them:

[source]
paths = ["app", "config", "database", "routes", "tests"]
excludes = ["tests/mago-corpus"]

[extension-hosts.unleashedpodcasts]
command = ["php", ".mago/worker.php"]

Confirm Mago sees it, then run your one rule against the codebase:

mago extension validate
Validated 1 extension(s) from 1 host(s).

mago extension list
Extension hosts:
  unleashedpodcasts (adaptive, up to 11 workers)
Registered extensions:
UnleashedPodcasts project rules (unleashedpodcasts/project-rules)
  Version: 1.0.0
  Linter rules: 1
    unleashedpodcasts/use-laravel-carbon (warning)

mago lint --only unleashedpodcasts/use-laravel-carbon
app/Services/BillingClock.php:5:5: warning[unleashedpodcasts/use-laravel-carbon]: Import Illuminate\Support\Carbon instead of Carbon\Carbon.
 = Help: Illuminate's Carbon subclass carries Laravel's macros and testing helpers.

warning: 1

Your house rule now runs itself, and because we attached a TextEdit, mago lint --fix will apply it.

One last sanity check before you trust the number: run the rule against your real codebase and diff its file list against a plain grep.

mago lint --only unleashedpodcasts/use-laravel-carbon | grep -oE '^[a-z].*\.php' | sort -u > /tmp/flagged
grep -rln 'use Carbon\\Carbon;' app tests | sort > /tmp/expected
diff /tmp/flagged /tmp/expected

Matching exactly means no false positives and no silent misses. This is also where that excludes line earns its keep: without it your corpus fixtures show up in the diff as phantom hits, because your root [source] scans tests.

What You Need To Know

  1. Mago extensions are PHP projects using the Mago\Sdk namespace, with a Rule, an Extension factory, and a worker entry point. composer require --dev carthage-software/mago gets you the SDK and the binary in one shot.
  2. getDefinition() supplies metadata and targets; lint() holds the logic and only receives the nodes you targeted. Read names with getResolvedName(), report with Issue::new(), and attach a TextEdit to get --fix for free.
  3. Behavior gets tested by corpus fixtures carrying @mago-expect. PHPUnit tests registration and metadata, because LintContext is not something you can build by hand.

 

Leave a comment

Use the form below to leave a comment:

 

Our Partners

Collaborating with industry leaders to bring you the best PHP resources and expertise