/// Article — May 2026
Stop Writing Coding Standards Documents. Write PHPStan Rules Instead.
/// Based on the talk
Custom PHPStan Rules: Automate Standards and Save Time
phpDay 2025. The video goes through the same ideas in more detail.
Watch the talk →Every team I've worked with has had a coding standards document.
Most of them were out of date. Some of them were ignored. A few were used as ammunition in code review ("section 4.2 says..."). None of them actually stopped a single bad line of code reaching the main branch.
A coding standard that isn't enforced by a machine is really just a suggestion.
This article shows you how to turn those suggestions into custom PHPStan rules. Once you've written a couple, you'll find a useful rule takes 5 to 10 minutes. It then checks every line of your codebase, on every commit, for free, forever.
And if you're using AI coding agents, it gets even better. More on that later.
The coding standards journey
Coding standards tend to go through three stages.
First, they live in someone's head. Usually the most senior developer's. You find out what they are when your pull request gets rejected.
Next, someone writes them down. A markdown file in the repo, or a wiki page. This is better, because at least new people can read it. But enforcement still depends on reviewers remembering every rule and spotting every violation.
Finally, they get automated. A tool checks them. The developer runs the tool locally, sees the problem, and fixes it before anyone else ever looks at the code.
That last stage gives you the tightest feedback loop possible. It also scales. A written standard gets harder to enforce as the team grows. An automated one doesn't care if you have 3 developers or 300.
Two kinds of coding standard
Not every standard needs a custom PHPStan rule. It's worth splitting them into two groups.
Formatting and style. Where the braces go. How use statements are ordered. PSR-12. For these, reach for PHP CS Fixer or PHP_CodeSniffer. They work at the token level and can fix problems automatically. There's no point writing PHPStan rules for these.
Rules that need to understand your code. These are the interesting ones. For example:
- URLs must be kebab-case, and URL parameters must be camelCase.
- Repository
get*methods must return a value or throw an exception.find*methods return a value ornull. - Services must have
readonlyproperties. - Boolean arguments must be passed as named arguments.
- All non-abstract classes must be
final.
You can't check these by looking at tokens. You need to know what a method call is, what class it's being called on, and what type its arguments are. That's where PHPStan comes in.
A quick detour into how PHP sees your code
To write a PHPStan rule you need a mental model of how PHP (and PHPStan) understand code.
When PHP runs your code it goes through a pipeline:
- The tokeniser splits the source into tokens.
- The parser turns those tokens into an abstract syntax tree (AST).
- The AST is compiled to opcodes.
- The opcodes run on the virtual machine.
Coding style tools look at step 1, the tokens. Static analysis tools like PHPStan look at step 2, the AST.
The AST represents all of your code as a tree of nodes. There are lots of node types, and each carries different information. A class declaration is a Class_ node with a name and a list of statements. A method is a ClassMethod node with a name, parameters and statements. A call like Route::get('/hello') is a StaticCall node.
PHPStan is built on Nikita Popov's PHP-Parser library, which provides a PHP class for every node type.
The best way to learn this is to play with it. Paste some code into the Rector AST explorer and look at the tree it produces. After a while you'll know the common node types without looking them up.
How PHPStan runs rules
This is the bit that made custom rules click for me.
PHPStan walks the entire AST of your codebase. For every node, it asks each rule: "Are you interested in this type of node?" If the rule is, PHPStan hands it the node. The rule returns zero or more errors. PHPStan collects all the errors and reports them.
Every rule, including all of PHPStan's built-in ones, implements the same interface:
interface Rule
{
public function getNodeType(): string;
public function processNode(Node $node, Scope $scope): array;
}
getNodeType() returns the class name of the AST node you care about.
processNode() gets called with each matching node. It returns an empty array if everything is fine, or an array of errors if it isn't.
The $scope parameter is where the magic happens. It gives you PHPStan's type information about the node in its position in the code. What type is this variable? What class am I inside? That's the difference between a PHPStan rule and a regex.
Example 1: enforcing a URL coding standard
Let's take a real standard. It comes from Spatie's Laravel guidelines: URLs must be kebab-case, and any parameters in them must be camelCase.
Route::get('/open-source', ...); // OK
Route::get('/open_source', ...); // Wrong: snake case
Route::get('/user/{userId}', ...); // OK
Route::get('/user/{user_id}', ...); // Wrong: parameter not camelCase
Step 1: write the tests first
I always start with the test fixtures. A fixture is a PHP file containing code that should be flagged, and similar-looking code that shouldn't be.
That second part is important. The quickest way to annoy a team is a rule that throws false positives.
<?php
// Fixtures/routes.php
Route::get('/open-source', fn () => 'ok');
Route::get('/open_source', fn () => 'bad'); // Error expected on this line
Route::post('/user/{userId}', fn () => 'ok');
Route::put('/user/{user_id}', fn () => 'bad'); // Error expected on this line
// Not a route definition, must not be flagged
SomethingElse::get('/open_source');
And the test itself extends PHPStan's RuleTestCase:
/**
* @extends RuleTestCase<RouteRule>
*/
final class RouteRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new RouteRule();
}
public function testRule(): void
{
$this->analyse(
[__DIR__ . '/Fixtures/routes.php'],
[
['URL must be in kebab-case and any parameters in camelCase', 6],
['URL must be in kebab-case and any parameters in camelCase', 8],
],
);
}
}
You give it the fixture files and a list of expected errors with line numbers. If the rule reports anything else, or misses anything, the test fails.
Step 2: find the node type
Paste Route::get('/hello') into the AST explorer and you'll see it's a StaticCall. Look at the StaticCall class in PHP-Parser and you'll find three public properties that tell you everything you need:
$class: the class being called (NameorExpr)$name: the method name (IdentifierorExpr)$args: the arguments
Step 3: write the rule as a series of questions
This is the pattern I use for every rule. Think of processNode() as a series of questions. If the answer to any question means "this isn't what I'm looking for", return early with no errors. Only if you get all the way to the bottom do you report an error.
<?php
declare(strict_types=1);
namespace App\Build\PHPStan;
use Illuminate\Support\Facades\Route;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<StaticCall>
*/
final class RouteRule implements Rule
{
private const HTTP_METHODS = ['get', 'post', 'put', 'delete'];
public function getNodeType(): string
{
return StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
// 1. Is the call on the Route class?
if (!$node->class instanceof Name) {
return [];
}
if ($node->class->toString() !== Route::class) {
return [];
}
// 2. Is the method one of get, post, put or delete?
if (!$node->name instanceof Identifier) {
return [];
}
if (!in_array($node->name->toLowerString(), self::HTTP_METHODS, true)) {
return [];
}
// 3. Is the first argument a literal string?
$arg = $node->args[0] ?? null;
if (!$arg instanceof Arg) {
return [];
}
if (!$arg->value instanceof String_) {
return [];
}
// 4. Is the URL valid?
if (RouteValidator::isValid($arg->value->value)) {
return [];
}
// 5. If we've got this far, there's a problem.
return [
RuleErrorBuilder::message('URL must be in kebab-case and any parameters in camelCase')
->identifier('route.url')
->build(),
];
}
}
A few things worth pointing out.
The @implements Rule<StaticCall> annotation tells PHPStan (and your IDE) that $node is a StaticCall, so you don't need to cast or check it. Yes, PHPStan rules use generics too.
RouteValidator is just ordinary PHP with a couple of regular expressions. It's not interesting from a PHPStan point of view, so I've left it out.
Every error has an identifier, route.url here. Identifiers let people ignore specific errors in specific places, and PHPStan 2 requires custom rules to provide them. Use RuleErrorBuilder and you're covered.
Don't try to be clever
Notice what the rule doesn't handle. If someone writes ($className)::get(...), or uses a variable for the method name, or builds the URL from a variable, the rule returns early and says nothing.
That's deliberate. Those cases are rare. Handling them would make the rule much more complicated. A rule that catches 99% of violations and is easy to read is far more valuable than a perfect rule nobody can maintain.
Step 4: register it
For rules specific to one project, I put them in a build/ directory alongside src/ and tests/, and add the namespace to autoload-dev in composer.json. Then register the rule in phpstan.neon:
services:
-
class: App\Build\PHPStan\RouteRule
tags:
- phpstan.rules.rule
That's it. The next time anyone runs PHPStan, the standard is enforced.
Example 2: making library upgrades painless
The second use case is one I think is underused, especially by library maintainers.
Imagine you maintain a library with an AlertService:
// v1.3
public function alert(string $message): void
For v2.0, you want to make callers specify an alert type:
// v2.0
public function alert(string $message, string $type): void
That's a breaking change. The Symfony way of handling this is to ship an intermediate release first:
// v1.4
public function alert(string $message, ?string $type = null): void
{
if ($type === null) {
@trigger_error('Not passing $type is deprecated', E_USER_DEPRECATED);
}
// ...
}
Runtime deprecation warnings are useful. But they only fire when that line of code actually runs. So you either need excellent test coverage, or you need to monitor production logs and wait for every code path to be exercised.
A static analysis rule finds every call site, right now, without running anything.
/**
* @implements Rule<MethodCall>
*/
final class AlertServiceAlertUpgradeRule implements Rule
{
public function getNodeType(): string
{
return MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
// 1. Is the method being called on an AlertService?
$alertServiceType = new ObjectType(AlertService::class);
$calledOnType = $scope->getType($node->var);
if (!$alertServiceType->isSuperTypeOf($calledOnType)->yes()) {
return [];
}
// 2. Is the method called alert?
if (!$node->name instanceof Identifier) {
return [];
}
if ($node->name->toLowerString() !== 'alert') {
return [];
}
// 3. Is there a second argument that is definitely a string?
$typeArg = $node->args[1] ?? null;
if ($typeArg instanceof Arg && $scope->getType($typeArg->value)->isString()->yes()) {
return [];
}
// 4. If we've got this far, there's a problem.
return [
RuleErrorBuilder::message('$type must be a string')
->identifier('acmeAlertUpgrade.alertType')
->build(),
];
}
}
This is where Scope earns its keep.
In step 1, we're not comparing strings. We ask PHPStan for the type of the expression the method is called on, then ask whether AlertService is a supertype of it. That correctly handles subclasses, injected dependencies, properties, and anything else PHPStan can work out the type of.
In step 3, isString()->yes() means "definitely a string". If the argument is string|null, the answer is "maybe", not "yes", so the rule flags it. That's exactly what we want, because passing null will break in v2.
Your fixtures need to include things like $this->alerter->info($msg) (different method) and $this->register->alert($msg) (different class) to prove the rule doesn't flag them.
Shipping upgrade rules as a package
Here's the bit I'd love more maintainers to do. Ship the upgrade rules as a separate Composer package:
{
"name": "acme/alert-service-v1-to-v2-upgrade-rules",
"type": "phpstan-extension",
"require": {
"acme/alert-service": "^1.4"
},
"extra": {
"phpstan": {
"includes": ["extension.neon"]
}
}
}
The phpstan-extension type makes it discoverable on Packagist. If your users have phpstan/extension-installer installed, the extra.phpstan.includes section means the rules are picked up automatically. And the ^1.4 constraint means the package only installs against the intermediate version, which is the only version these rules make sense for.
Your users run PHPStan, get a list of every line they need to change, fix them, and upgrade to v2.
Running upgrade rules separately
On a small codebase, just add the upgrade rules to your normal PHPStan config and fix everything in one go.
On a big codebase with lots of call sites, that's going to break the build for everyone. Instead, create a separate config that runs only the upgrade rules:
# phpstan-upgrade.neon
includes:
- vendor/acme/alert-service-v1-to-v2-upgrade-rules/extension.neon
parameters:
customRulesetUsed: true
paths:
- src
Run it with:
vendor/bin/phpstan analyse -c phpstan-upgrade.neon
Now one person or team can own the upgrade and work through the list, while everyone else carries on as normal.
Example 3: rules that replace tests
This one came from a bug I shipped.
I maintain PHP Language Extensions, which adds attributes like #[Friend] and #[NamespaceVisibility] to PHP, enforced by PHPStan rules. Each rule has to be registered in an extension.neon file.
I wrote a new rule. I tested it. It worked perfectly. I released it.
It did absolutely nothing, because I'd forgotten to add it to extension.neon.
The usual response to a bug is to write a test that would have caught it. But here I'd need a test for every rule, and I'd have to remember to write that test for every future rule. Which is the same kind of remembering that caused the bug in the first place.
So I wrote a PHPStan rule instead:
/**
* @implements Rule<InClassNode>
*/
final class CheckRuleIsInExtensionRule implements Rule
{
public function __construct(
private ExtensionFileChecker $extensionFileChecker,
) {
}
public function getNodeType(): string
{
return InClassNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
if (!$classReflection->implementsInterface(Rule::class)) {
return [];
}
if ($classReflection->isAbstract()) {
return [];
}
$className = $classReflection->getName();
if ($this->extensionFileChecker->isInFile($className)) {
return [];
}
return [
RuleErrorBuilder::message("Rule [$className] not in extension.neon.")
->identifier('extension.ruleNotRegistered')
->build(),
];
}
}
InClassNode is a virtual node that PHPStan provides for checks that apply to a whole class. It gives you the class reflection, so you can ask questions about the class without walking its AST yourself.
One rule. It covers every rule that exists now and every rule anyone will ever add.
That's become a habit for me. When a bug reaches production, I ask how it got there. Then, alongside "what test would have caught this?", I ask "could a PHPStan rule have caught this?"
If the answer is yes, write the rule. A test prevents that bug. A rule prevents that entire class of bug, everywhere in the codebase.
To be clear, I'm not saying stop writing tests. But for checks about configuration and setup, a rule is often more complete and less work.
Why this matters even more with AI
I've been using AI coding agents a lot over the last year. Here's something I've learnt.
You can write your coding standards in AGENTS.md or CLAUDE.md. The agent will read them. It'll follow them most of the time.
Most of the time.
Natural language instructions are probabilistic. A PHPStan rule is deterministic. It either passes or it doesn't.
If your agent runs PHPStan as part of its loop (and it should), then every custom rule becomes a guardrail the agent enforces on itself. It writes code, runs PHPStan, sees the error, fixes it. No human reviewer needs to spot the problem. No hoping the agent remembered paragraph 12 of the instructions.
The flip side is that AI is good at writing these rules too. The approach that works best for me mirrors the process above:
- Start with the test cases. Code that should trigger the rule, and similar code that shouldn't.
- Ask it to identify the AST node type.
- Build the rule using the early-return pattern.
- Run the tests and verify.
Pause for review at each step. It's much easier to correct a wrong fixture than a wrong rule.
A recipe you can follow
Here's the process in one place:
- Tests first. Write a fixture with code that should be flagged and similar code that shouldn't. Write a
RuleTestCasewith the expected errors and line numbers. - Find the node type. Use the AST explorer. Read the public properties of the PHP-Parser node class.
- Write the rule as questions. Return
[]as soon as you know the node isn't what you're looking for. Report the error at the end. - Use
Scopefor types. UseObjectType::isSuperTypeOf()andisString()->yes(), not string comparisons. - Keep it simple. Bail out on dynamic and exotic cases.
- Always add an identifier.
- Register it in
phpstan.neon, or package it as aphpstan-extension.
Where to start
Look at the last few comments you left in code review. How many of them were about something a machine could have spotted?
Pick one. Write the rule. It'll probably take you less time than it took to leave those comments.
If you want to see all of this in action, watch the full talk from phpDay 2025. The example code is on GitHub, and the PHPStan docs on writing rules are excellent.
What's the first coding standard you'd automate?
Start a conversation
We help teams put these ideas into practice, through training, code review and working alongside you. Tell us where you are and what you're aiming for.