---
title: "How to lint PHP code within the source"
url: https://www.exakat.io/how-to-lint-php-code-within-the-source/
date: 2026-09-28
modified: 2026-09-28
lang: en
author: "dams"
description: "How to lint PHP code within the source Ahead-of-time languages check a closed world. Compile a Rust crate, a Go module, a C project with two functions named parse_config, and..."
categories:
  - "Code auditing"
tags:
  - "feature"
  - "lint"
  - "php"
image: https://www.exakat.io/wp-content/uploads/2026/09/inside.640.jpg
word_count: 1820
---

# How to lint PHP code within the source

# How to lint PHP code within the source

Ahead-of-time languages check a closed world. Compile a Rust crate, a Go module, a C project with two functions named `parse_config`, and you never get to run anything at all: the compiler builds the whole symbol table before it hands anything to a linker, and a duplicate name is a compile error you read on a screen that has executed exactly zero lines of your program. Everything gets loaded, every symbol gets resolved, every collision gets flagged, and only then does a single instruction run. The checking and the running are two different eras, and the first one is not allowed to end quietly.

PHP was never built this way. There is no linker, and there is no moment where the language looks at your whole codebase and pronounces it sound and complete. It compiles and runs file by file, as `include` and `require` pull them in, in whatever order your own conditionals decide today. A "PHP program" isn't a fixed graph of symbols the way a Rust crate is: it's whatever subset of your files happened to get pulled into this particular execution, given this particular input. So the question "is this PHP code valid?" is not a single question. It's actually three distinct questions. Let's see what we can understand of these.

## Lint PHP like a human being

The obvious move, from inside a running PHP process, is to treat PHP as a black box and interrogate it from outside:

This totally works. It's also faintly absurd: you are already running an interpreter, and your answer to "does this code parse" is to fork a second one, wait for it to boot, and scrape its stdout. It's the "turn it off and on again" of static checking: dependable, and a small admission that you don't trust the interpreter you're standing inside of. It's also the wrong tool the moment you want this per-keystroke in a language server rather than once per CI run: a process fork per file doesn't scale to that, and it hands you a string to grep instead of a tree to walk.

The only valid use case is when you want to test the code with a different PHP versions than the one you're currently running. But that is a specific use case for static analysis authors, so that doesn't apply to a lot of us.

PHP can answer the same question about itself, natively, three times, at three different depths. The hidden issue is that all these answers cover different grounds. They don't check the same thing than the previous one, but more. They check three non-overlapping categories of rules, and the deepest one only agrees to check anything if you also let it run.

## All your tokens are belong to us

[`token_get_all()`](https://php-dictionary.readthedocs.io/en/latest/index/token.ini.html), or its object-oriented sibling `PhpToken::`[`tokenize()`](https://php-dictionary.readthedocs.io/en/latest/index/phptoken.ini.html), added in PHP 8.0, is a pure [tokenizer](https://php-dictionary.readthedocs.io/en/latest/index/tokenizer.ini.html). It pattern-matches its way through a string and hands back an array of tokens. It has no concept of grammar, no concept of balance, no memory of what it saw two tokens ago.

That is not a bug in the tokenizer, it's the entire point of it: a syntax highlighter has to colour code you haven't finished typing yet, and a formatter has to touch files that don't compile so it can hand them back still broken but reindented. Rejecting anything would make it useless for the one job it's actually built for.

In fact, it is quite possible to clean these tokens, or invent pre-processing rules on these tokens, that could produce valid PHP code.

## Tokens, meet grammar rules

`token_get_all($code, TOKEN_PARSE)`, added in PHP 7.2, feeds the same tokens through PHP's real [parser](https://php-dictionary.readthedocs.io/en/latest/index/parser.ini.html), that is the one that builds the [Abstract Syntax Tree](https://php-dictionary.readthedocs.io/en/latest/index/ast.ini.html) PHP itself compiles from. Now grammar actually matters, and a real [`ParseError`](https://php-dictionary.readthedocs.io/en/latest/index/parseerror.ini.html) shows up, catchable this time:

The same string handed to plain `token_get_all()` returns four tokens and no complaint at all. Such a dangling statement isn't a lexical fact, it's a grammatical one, and the default tokenizer doesn't have grammar. `TOKEN_PARSE` does, but a parser only builds a tree; it has no symbol table. You can ask it something that requires remembering a name across two declarations rather than reading a declaration's shape, and it goes quiet. This is because it isn't a grammar fact either:

Grammatically, nothing is wrong here. The parser checks each declaration in isolation against a rule that checks if this looks like a class, or an interface or else; it was never asked to remember which names it has already seen. Naming is difficult, and it it the next step.

## The Only Judge That Counts

Run the same string instead of parsing it, and the [compiler](https://php-dictionary.readthedocs.io/en/latest/index/compiler.ini.html) finally objects:

Note that is not a catchable error. Try to wrap it in `try`/`catch (\Throwable)` and this still ends the script. It requires PHP to actually compile the file into [opcodes](https://php-dictionary.readthedocs.io/en/latest/index/opcode.ini.html) and register each [declaration](https://php-dictionary.readthedocs.io/en/latest/index/declaration.ini.html) into the runtime's symbol tables as it goes. That pass lives in the execution phase of PHP, not only the parsing side.

Here PHP has one more surprise, and it part of the folklore of PHP syntax. Forward references work! You can call a function or instantiate a class defined further down the same code, because unconditional top-level declarations get bound early, during compilation, before the first opcode runs. So does that mean a duplicate class is caught the same way, as a fact about the whole file, before anything executes? Put an `exit()` between two unconditional declarations and ask PHP directly:

`reached
stopped
?>
[/php]

No fatal error, ever. The second class A {}` needed to actually execute to be judged, and `exit()` denied it the chance. The redeclaration simply never happened. Do the identical experiment with two `function f() {}` instead, and the file dies before printing a single character:

`PHP Fatal error: Cannot redeclare function f() (previously declared on line 2) in ... on line 5
?>
[/php]

echo "reached\n"` never fires. The conflict was caught during compilation of the whole file, exactly the way an AOT linker would, before execution had a chance to reach line 2. Traits and interfaces side with classes, not with functions: believe me, bro, I checked all three. So of PHP's three kinds of user declaration, exactly one gets the ahead-of-time treatment, and it's the one nobody would have bet on. Class, trait, and interface collisions are genuinely runtime facts, dependent on which lines actually execute, and if autoload is triggered or not; function collisions are compile-time facts about the file as a whole, indifferent to whether execution ever gets there. The same keyword, `eval`, enforces two different eras of checking depending on which kind of name you handed it.

Now, you can put a class inside a conditional, and this runtime dependency stops being a curiosity and starts being a way to hide a collision entirely:

Two declarations named `Formatter`. Neither `TOKEN_PARSE` nor a hundred ordinary runs of this file will ever complain, because a single run only ever walks one branch — one `Formatter` gets declared, no collision, forever, as far as that execution is concerned. The clash isn't a property of the text. It isn't even a property of a single run. It only exists across runs that disagree with each other about `$legacyMode`, which is a sentence that has no meaning until you fix an execution and then fix another one to compare it against.

## What Each One Actually Sees

| | tokenizes text | checks grammar | catches `ParseError` | catches a class/trait/interface collision | catches a function collision |
| --- | -------------- | -------------- | -------------------- | ----------------------------------------- | ---------------------------- |
| `token_get_all()` | ✅ | ❌ | ❌ | ❌ | ❌ |
| `token_get_all(TOKEN_PARSE)` | ✅ | ✅ | ✅ | ❌ | ❌ |
| `include` / `eval` | ✅ | ✅ | ✅ | only if that line executes | always, whole file, before anything runs |

When I wrote this table, I realized it looks like a ladder: each string of checks is strictly stronger or longer than the last. But I had to stop using emojis for the last last two columns: `include` doesn't have one uniform "now I also check declarations" superpower, it has two different policies depending on what kind of name is colliding, and only one of those policies is the AOT-style, whole-file, checked-before-anything-runs guarantee the opening of this piece described. The other is a genuinely runtime fact. It is true only when a specific execution reaches the conflicting line, silent about every execution that didn't. You can't know before execution starts, and, even at that, long time after it has started.

Under an ordinary `php-fpm` request, that silence is almost cosmetic: each request is a fresh process, one branch wins, the process dies, nothing ever compares notes with the branch that didn't run. Under a persistent worker, such as RoadRunner, Swoole, FrankenPHP, or any of the newer tools that makes PHP run for a long time, that isolation is exactly what's gone. An early request can take the legacy branch, a later request B can take the modern one, in the same worker, and the two class declarations that a linter, a parser, and every previous request found perfectly innocent finally meet, weeks after the branch was written. Kaboom.

Static analyzers exist to close this gap without paying for an execution: Psalm, PHPStan, exakat's own engine walk both arms of every branch at once, something no single run of `include` will ever do. That's real ground gained over the three built-in tools above. But it's an approximation of running every path, not a report from having run one, and it inherits every hazard of predicting a program instead of asking it. Which leaves an uncomfortable question to answer: is this PHP code valid? The lexer won't answer completely, the parser won't answer completely and it is still down to the PHP engine, during execution, to check how something is running or not.

Unless you write the code without any possible branching such as the one we covered above.