PHPantom — Diagnostics¶
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
Severity philosophy¶
PHPantom assigns diagnostic severity based on runtime consequences:
| Severity | Criteria | Examples |
|---|---|---|
| Error | Would crash at runtime. The code is definitively wrong. | Member access on a scalar type ($int->foo()). Calling a function that doesn't exist (doesntExist()). |
| Warning | Likely wrong but could work for reasons we can't verify statically. The types are poor but the code might be correct at runtime. | Accessing a member that doesn't exist on a non-final class ($user->grantAccess() where User has no such method but a subclass might). Unknown class in a type position (Class 'Foo' not found). Subject type resolved to an unknown class so members can't be verified. |
| Hint | The codebase lacks type information. Off by default or very subtle. Poorly typed PHP is so common that showing these by default would be noise for most users. Anyone who does care about type safety is likely running PHPStan already. Unless our engine becomes very strong, these diagnostics either expose our own inference gaps or bother users who never opted into static analysis. | mixed subject member access (opt-in via unresolved-member-access). Deprecated symbol usage (rendered as strikethrough). |
| Information | Advisory. Something the developer might want to know. | Unused use import (rendered as dimmed). Unresolved type in a PHPDoc tag. |
D5. External tool diagnostic suppression actions¶
Impact: Low · Complexity: Low (per tool, after proxy exists)
PHPantom's own inline suppression (// @phpantom-ignore code) has
shipped. PHPStan suppression is also implemented ("Ignore PHPStan
error" / "Remove unnecessary @phpstan-ignore"). The PHPCS proxy itself
has also shipped (src/diagnostics/external/phpcs.rs, [phpcs] config
section), but nothing wires up a suppression action for it yet. What
remains is wiring up suppression actions for additional external tool
proxies:
- PHPCS:
// phpcs:ignore [Sniff.Name]or// phpcs:disable/// phpcs:enableblocks. The proxy exists; only the suppression action is missing. - PHPMD (3.0):
#[SuppressWarnings(RuleName::class)]as a PHP attribute on the enclosing class or method, importingPHPMD\Attribute\SuppressWarningsand the rule class. The proxy (src/phpmd.rs,[phpmd]config section) carries the rule name as the diagnostic code; only the suppression action is missing. The rule's class is not in the report, so the action has to map the rule name to itsPHPMD\Rule\…class.
D15. Unused parameter diagnostic¶
Impact: Low · Complexity: Medium
Flag function and method parameters that are never read inside the
body. This was intentionally excluded from D4 (unused variable
diagnostic) because false positives are common for callbacks, interface
implementations, and framework conventions (e.g. Laravel event
listeners) that require specific parameter signatures even when not
all parameters are used. Users can now silence false positives with
// @phpantom-ignore unused_parameter.
Scope¶
- Function and method parameters (including closures and arrow functions) that are never read inside their body.
- Constructor parameters that are not promoted and never read.
Exclusions¶
- Parameters named
$_or starting with$_(intentional discard). - Promoted constructor parameters (they are property assignments).
- Parameters in abstract methods and interface method signatures (no body to check).
D16. unreachable_match_arm ignores literal subject types¶
Impact: Low-Medium · Complexity: Medium
scalar_type_label in src/diagnostics/match_type_errors.rs answers
None for a literal type ('exception', 42), so a subject the
resolver typed as one exact value never reaches the arm check and no
arm is ever reported unreachable. The comment there explains why: a
literal was as often what survived after resolution lost an
alternative it could not type as it was a genuine one-value subject,
and taking the claim without that evidence produced false positives.
The resolver no longer loses those alternatives. An unresolvable branch now widens the union it belongs to instead of dropping out of it, so a literal that reaches this diagnostic is a claim the resolver stands behind.
Fix: Give TypeKind::Literal its scalar kind in
scalar_type_label and check the resulting arms. Cover the case the
old comment was guarding against with a test: a subject whose other
branch cannot be typed must still produce no diagnostic, because it
now resolves to mixed rather than to the surviving literal.
D17. docblock_native_mismatch only judges nullability¶
Impact: Low · Complexity: Medium-High
/** @param int $name */
function greet(string $name): void {} // not flagged: int is not string at all
/** @param Foo $value */
function take(Bar $value): void {} // not flagged: `Foo` may alias `Bar`
src/diagnostics/docblock_native_mismatch.rs compares a documented type
against its native hint on one axis only: whether the annotation admits a
null the signature rules out. A documented type that is not a subtype of
the native hint on any other axis (@param int on a string, @return
array on a : string) stays silent. That is the check PHPStan's
IncompatiblePhpDocTypeRule performs, and the one the existing
is_type_compatible in src/diagnostics/type_errors/compatibility.rs
already has the machinery for.
Covering it means resolving the documented type's bare names first, since a
Foo that a @template list or an imported @psalm-type alias stands
behind may well be the native Bar after resolution. The nullability axis
sidesteps that today: a name that resolves to something nullable never
spells null itself, so the check simply does not fire on it.
Fix: Resolve the documented type's names against the declaration's own
@template list, the enclosing class's, and the file's
@psalm-type/@psalm-import-type tags, then run the comparison through
is_type_compatible rather than the nullability test alone.
D18. array<int, T> is accepted wherever a list<T> is declared¶
Impact: Low · Complexity: Medium-High
/**
* @param list<int> $values
* @return list<int>
*/
function keep(array $values): array {
return array_filter($values, fn ($v) => $v > 3); // array<int, int>, not flagged
}
is_type_compatible in src/diagnostics/type_errors/compatibility.rs
carries an explicit MAYBE hatch for array<int, X> reaching a list<X>
parameter or return type, on the grounds that PHP codebases spell the two
interchangeably. The core is_subtype_of already rejects the direction
(only list<X> satisfies array<int, X>, not the reverse), so the hatch
is the only thing standing between us and PHPStan's report here.
Now that array_filter() reports the array<int, T> it actually
produces, the hatch is what keeps the second half of the over-claim
alive: a function that hands back an unwrapped filter result still
passes a declared list<T>.
Fix: Drop the array<int, X> → list<X> arm and audit the corpus
under projects/ for what it starts reporting. The arm exists because
plain array<int, X> is what an unannotated array resolves to in many
places, so retiring it wants the resolver to answer list<X> for the
shapes that genuinely are lists (literal arrays, array_values(),
appended-to locals) first. Pay for it with resolver precision, the same
way the supertype-where-subtype hatch was retired.
D19. invalid_member_access cannot tell a property read from a write¶
Impact: Low-Medium · Complexity: Medium
PHP dispatches an unreachable property to a different magic method
depending on what is being done to it: __get for a read, __set for a
write, __isset for isset(), and __unset for unset(). The span the
visibility check runs on records only that a property was accessed, so
the check cannot pick the handler that actually applies and treats the
presence of any of the four as reason enough to stand down.
The result is a missed report rather than a wrong one. A class that
declares __set but no __get silences a read it would in fact fatal
on, and the same holds for every other mismatched pairing.
Fix: Carry the operation on the MemberAccess span — read, write,
isset, unset — the way readonly_writes.rs recovers write targets
from the AST, and require the handler that matches it. The same
information would let the readonly check drop its own separate walk.
D20. Foo::$bar and Foo::bar are the same span¶
Impact: Low · Complexity: Medium
Symbol extraction strips the $ from a static property access
(member_access.rs) and records only a name plus is_static, so a
static property and a class constant of the same name are
indistinguishable downstream. Every consumer has to guess a precedence;
the visibility check tries the constant first.
Two classes of mistake follow, both needing a class that declares a constant and a static property under one name:
class Collision {
private const token = 'constant';
public static string $token = 'property';
}
echo Collision::$token; // reported as a private constant
and the reverse pairing, where a genuinely unreachable static property is passed off as a public constant and nothing is reported.
Fix: Record the member kind the syntax actually names — instance property, static property, constant, or method — rather than a name and a static flag.
D21. A union of an unreachable and a missing member is reported by neither check¶
Impact: Low · Complexity: Medium-High
Known|Other where Known::$x is private and Other has no $x at all
fails on every runtime branch, and nothing reports it. Both checks are
conservative in the same direction and each assumes the other covers what
it declines to judge: the unknown-member check stays silent because the
member exists on one branch, and the visibility check stays silent
because it does not exist on the other.
A related gap sits one layer earlier: the resolver hands the diagnostics only the branches it could load and drops the rest, so neither check can see that a union had a branch it failed to resolve. Any verdict that wants to distinguish "no branch permits this" from "we could not read one of the branches" needs that information preserved.
Fix: Give the branches of a resolved union a verdict of their own — accessible, inaccessible, missing, unresolved — instead of collapsing them to a list of loaded classes, and decide the union once from those.
D22. Member provenance is recomputed instead of recorded¶
Impact: Medium · Complexity: Medium-High
The inheritance merge knows which class each member it folds in came
from — merge_traits_into() is handed the host FQN — and drops that on
the floor. MethodInfo, PropertyInfo, and ConstantInfo have no field
for it, so the assembled class says what a class has and never who
declared it.
Every check that needs the declaring class therefore recomputes it by
walking the raw hierarchy, and every such walk is a partial
reimplementation of the merge's own rules. invalid_member_access has
one, and it is already known to be incomplete: it does not apply
trait_aliases, so a member a use clause renamed is untraceable and
the access goes unreported.
trait Opens { public function open(): void {} }
class Vault { use Opens { open as private hidden; } }
class Intruder {
public function probe(Vault $vault): void
{
$vault->hidden(); // fatal at runtime, reported by nothing
}
}
The access has to be written inside a class to show the gap. From outside every class the check reports anyway, because no scope can reach a non-public member whoever declared it, and it falls back to naming the receiver when the declaration cannot be traced.
Fix: Record the declaring class during the merge and let the checks read it, rather than each one re-deriving the merge's rules. The cost is a field on every member across the whole index, so it wants measuring against the memory the index already uses before it is committed to.
D23. A rebound closure's scope is added to the lexical one rather than replacing it¶
Impact: Low-Medium · Complexity: Medium
A closure can run with a different class as its scope than the one it is
written in — Laravel's Macroable, anything through Closure::bind, and
anything a @param-closure-this tag describes. The resolver already
works this out. The visibility check does not read it; it infers from the
subject text that $this, self, or static names a binding and adds
the receiver as an additional scope, keeping the enclosing class as
well.
Adding rather than replacing is safe in the direction that matters — it cannot invent a report — but it hides one:
class Owner {
private static function hidden(): void {}
public function boot(): void
{
Target::macro('x', function (): void {
self::hidden(); // fatal: the closure's scope is Target
});
}
}
class Target extends Owner {
/** @param-closure-this static $macro */
public static function macro(string $name, callable $macro): void {}
}
The closure is written inside Owner, so the enclosing class permits the
call, while at runtime the scope is Target and a parent's private
member is out of reach.
Fix: Take the bound class from the resolver, which already computes it, and let it replace the enclosing class rather than joining it. Inferring a binding from the spelling of the subject is guessing at something the type engine has already decided.
D24. A match that does not cover every enum case is not reported¶
Impact: Medium · Complexity: Medium
enum Suit: string { case Hearts = 'H'; case Spades = 'S'; }
function name(Suit $s): string {
return match ($s) { // should be reported: Suit::Hearts throws UnhandledMatchError
Suit::Spades => 'spades',
};
}
function describe(Suit $s): string {
if ($s->value === 'H') {
return 'hearts';
}
return match ($s) { // must stay silent: only Spades reaches here
Suit::Spades => 'spades',
};
}
A match with no default arm throws UnhandledMatchError for any subject
value no arm covers. When the subject resolves to a closed set (an enum, a
union of enum cases, a bool, or a union of literals), subtract each arm's
conditions from it and report what is left. PHPStan (match.unhandled),
Psalm (UnhandledMatchCondition), mago and Qodana all do.
The second function is part of the job, not a follow-up. The check can only
stay quiet there if a comparison on a backed enum's ->value narrows the
enum itself, so $s->value === 'H' has to remove Suit::Hearts from $s
on the fall-through path. Without that narrowing the check would be a new
false positive. PHPStan and Psalm report the second function too, and the
suite counts that against them.
Found running the php-typing-conformance suite
(regressions_backed_enum_value_narrowing.php). A quick fix to add the
missing arms is H16 (match.unhandled), which could
then attach to this diagnostic instead of only PHPStan's.
D25. Two traits declaring the same property with different types is not reported¶
Impact: Low · Complexity: Low-Medium
trait Left { public string $prop; }
trait Right { public int $prop; }
final class Composed { use Left; use Right; } // fatal: Left and Right define the same property ($prop) in the composition of Composed
PHP only allows the same property from two traits (or from a trait and
the class) when the declarations are compatible: same visibility, same
type, same readonlyness, and same default. Anything else is a
compile-time fatal. The inheritance merge already sees both declarations;
it keeps one and discards the other without comparing them.
Found running the php-typing-conformance suite
(regressions_trait_property_type_conflict.php). mago and Phan report it.
Where to look: the trait merge in src/inheritance/traits.rs. The
report could sit beside the missing-method check in
src/diagnostics/implementation_errors.rs.
D26. Reading a typed property that nothing initialises is not reported¶
Impact: Low-Medium · Complexity: Medium-High
final class User { public string $name; }
$user = new User();
echo $user->name; // Error: must not be accessed before initialization
A typed property without a default starts uninitialized, and reading it
throws. The declaration-side check is the tractable half: flag a typed,
non-promoted property with no default that no constructor path assigns.
Psalm's MissingConstructor works this way. The read-side check needs
definite-assignment tracking across the constructor and is the harder half.
Keep it conservative. Frameworks and ORMs hydrate properties by reflection
(Doctrine entities, serializers, #[Inject]), and a diagnostic that flags
all of them is a false positive on correct code. Consider exempting classes
whose properties carry an attribute or an ORM mapping docblock, and put the
check behind a [diagnostics] toggle if a safe default can't be found.
Found running the php-typing-conformance suite
(properties_uninitialized_read.php). Psalm and Qodana report it.
D27. Destructuring offsets an array cannot have is not reported¶
Impact: Low · Complexity: Medium
/** @return array<string, int> */
function stringKeyed(): array { return ['a' => 1]; }
[$a, $b] = stringKeyed(); // should be reported: offsets 0 and 1 cannot exist on array<string, int>
[$a, $b] = … reads offsets 0 and 1. When the right-hand side's key type
excludes them (string keys only, or a shape without those keys), the
destructure yields null with a warning at runtime. The destructuring
resolver already reads the key and value types. It could report a
positional destructure of a string-keyed array, and a keyed one (['x' => $x]
= …) of a shape that lacks the key.
Found running the php-typing-conformance suite
(regressions_list_destructure_string_key.php). PHPStan and mago report it.
D28. "Remove unreachable code" is wired to PHPStan only¶
Impact: Low-Medium · Complexity: Medium
The action reads phpstan_tool.last_diags and nothing else
(code_actions/phpstan/remove_unreachable.rs), so the native
unreachable_code diagnostic never offers it. Adding the code to the
trigger is not enough on its own: the resolve step deletes from the
diagnostic's line to the next closing brace rather than using the
diagnostic's own range, which
- has nothing to delete for a dead run at the top level of a file, where no closing brace follows;
- ignores the reported span, so it would remove more than was dimmed;
- can swallow a hoisted declaration or a
gotolabel sitting inside the run, both of which the diagnostic deliberately leaves reachable.
Fix: Take the range from the diagnostic and carry it through to the
resolve payload, and let the action accept a native diagnostic rather
than only a proxied one. Moving the file out of phpstan/ is the
smallest part of it.
D29. namespace and declare bodies break the reachability flow¶
Impact: Low · Complexity: Low-Medium
unreachable_code treats a braced namespace and a declare body as
fresh statement lists rather than as the transparent wrappers they are,
so reachability neither flows into them nor out of them:
<?php
namespace First {
return; // ends the whole file
}
namespace Second {
echo 'never'; // not reported
}
and, inside a function:
Neither wrapper is itself a runtime statement, so neither should be dimmed, but the state on either side of it has to carry through.
Fix: Thread the reachable/unreachable state through both wrappers instead of restarting the scan inside them.