Code patterns
Recurring practices mined from this codebase (handlers, stubs, tests) in 2026-07, each with the evidence that makes it a practice rather than an anecdote. Evidence cites files and symbols, not line numbers, so grep for the named method when a reference feels stale. Read this before writing a handler, stub, or test. The rationale behind many of these lives in Architecture Decisions.
Choosing the mechanism
Pick by failure shape, not by escalation. Stubs are the default: prefer one wherever a docblock can express the semantics, since a stub carries zero lifecycle surface (see the principle in decisions.md):
| Failure shape | Mechanism |
|---|---|
| Static semantics, expressible in a docblock | stub in stubs/common/ |
| Behavior valid only on newer Laravel | stub in stubs/<version>/ (widening common is a silent false negative on the older floor, see the SortDirection note in stubs/13.8.0/Database/Query/Builder.phpstub) |
| Needs call-site context, argument flow, or config | handler |
| Whole-codebase per-model facts (schema, casts, relations) | ModelMetadataRegistry via AfterCodebasePopulated |
When every stub shape you can write is blocked by an upstream Psalm limitation, stop and record the probed alternatives plus the chosen trade-off in decisions.md, and pin a regression test to that decision. Without the record, later contributors re-probe the same dead ends or “fix” a working workaround.
Handlers
- Decline with
null; never emit a guessed type. A non-null answer permanently overrides Psalm’s own inference and its own issues; declining is the only way a typo still reachesUndefinedMagicMethodand a stub still wins. (CacheManagerReturnTypeHandler::getMethodReturnType(); 35 declines inValidatedTypeHandleralone.) - Gate a per-class method provider on the method name before anything else. Registration is per class, so Psalm offers the provider every method on that receiver; un-gated providers rewrite unrelated methods (the #1075
ContainerHandlerregression). - Order per-expression bails cheapest first: AST
instanceof, thenIdentifiername, then astrtolowercompare against a const map, and only then any$codebaselookup. Taint handlers additionally bail when$codebase->taint_flow_graph === nullso non-taint runs pay nothing; neverinstanceofitsPsalm\Internalclass. Defer the suppression-awareCodebase::tracksTaintFlow()until an expression matches: it looks up suppressed issues on every call. (OctaneIncompatibleBindingHandler,ImplicitQueryBuilderCallHandler.) - Resolve the receiver to exactly one known class or bail. Union receivers, mixed atomics, and userland subclasses turn a helpful narrowing into a false positive on correct code, the plugin’s dominant failure mode. Taint strips and sinks gate on the actual receiver, never just the argument shape (#1306). (
ImplicitQueryBuilderCallHandler,WhereColumnTaintHandler.) - Answering for a facade
@methodpseudo-method requires implementing BOTH the return-type and params providers, gated on the samemethodExists()lookup (with_pseudo: falsediscriminates real from pseudo). A return without params drives Psalm intogetMethodParams()and fatals. (CacheManagerReturnTypeHandlerand the other dual-interface handlers.) - Static state that carries booted-app or per-run facts gets
public static function reset(): void, registered inPlugin::resetInvocationState(), which runs before boot on every invocation. The plugin can be re-invoked in one process (language server, tests); without the reset a previous app’s aliases and config leak into the next run. Process-invariant caches need no reset, and per-file scratch state may instead clear inside its own hook. (39reset()definitions repo-wide.) - Storage lookups that can fire before a class is scanned or populated (
classlike_storage_provider->get(),classExtends(),getDeclaringMethodId()) sit incatch (\InvalidArgumentException|UnpopulatedClasslikeException)with the failure treated as “not proven”. Storage can be missing or unpopulated depending on scan order, and an uncaught throw aborts the whole Psalm run; a lookup guaranteed to run post-population may skip the catch. (38 files catch the first, 10 also the second.) - Ancestry checks on a class name taken from an analyzed type use
ClassLineage::isA($codebase, $class, X::class), never\is_a($class, X::class, true)(norclass_exists()/enum_exists()with autoload); other storage lookups on such a name go throughClassLineage::storage(). Autoloading loads the analyzed class’s file, and Psalm’s error handler (active after boot and in forked workers) turns a load-time deprecation there into an exception that crashes the run, drops a model’s metadata inside the registry warm-up, or, at plugin init, disables the plugin (#1253, #1652). Both helpers strip a leading backslash and resolveclass_alias()names, which Psalm keeps in method return types and in ancestry maps (implements SomeAlias).\is_a()stays fine on a class the plugin has already loaded itself. Where a name may legitimately be unscanned (a string cast target), fall back to the runtime only when storage has no entry, and autoload each name at most once per process, then check withautoload: false(CastResolver::autoloadOnce()): a file that failed mid-load keeps its earlier declarations, so a second include is an uncatchable “Cannot redeclare” fatal. Init-time code that runs before storage is populated wraps its autoloading check incatch (\Throwable). ExistingCodebase::classExtends*()/classImplements()calls inside the catch above remain fine; no mass migration. (EmissionAppTest::autoloading_a_class_named_by_an_analyzed_type_does_not_crash_or_drop_the_plugin.) - Register facade receivers three ways: the service class, the hardcoded canonical facade, and
FacadeMapProvider::getFacadeClasses()for the app’s configured root aliases. Psalm dispatches providers by exact FQCN, and apps that trimFacade::defaultAliases()would otherwise lose the handler. (CacheManagerReturnTypeHandler::getClassLikeNames(); 8 handlers.) - Every
IssueBuffer::accepts()passes the suppressed-issues list ($source->getSuppressedIssues()in statement hooks,$storage->suppressed_issuesin class-visit hooks), or the custom issue is unsuppressable by@psalm-suppress. (13 of 13 call sites.) - Gate definitive negative judgments on
ModelMetadata::isComplete(SECTION_*). Emitting an issue (“this attribute does not exist”) requires the relevant section to be complete; positive enrichment may consume partial metadata. A set section bit is authoritative even when the section is empty, and issue-emitting consumers additionally bail on an empty schema map because dynamic migrations can hide columns. (UnknownModelAttributeHandlergates;BuilderScopeHandlerreads partial data.) - Precompute lookup tables as
array<lowercase-string, true>class constants and memoize per-class results in areset()-cleared static. Hooks fire once per expression across the project. Annotate helpers with whatMissingPureAnnotationsuggests (psalm --alter --issues=MissingPureAnnotation); methods touching a static stay unannotated. (TimingUnsafeComparisonHandler.) - The class docblock answers “why is this a handler and not a stub”, cites the issue number, and cross-references hand-duplicated twin code with a “keep in sync” note. It is the most-repeated review question. (45 handler files cite an issue.)
Stubs
- Declare deliberately weak types rather than inventing precision.
Query\Builderleavesmin()/max()unstubbed (reflectedmixed) andRelation.phpstubmirrors that with an explicit@return mixedsoBuilderAggregateHandlercan narrow known columns later; the carbon stubs leave methods entirely undeclared so reflection of the real class fills them. A confident-but-wrong docblock is worse thanmixed. - Fluent accumulators get fresh template parameters plus
@psalm-this-out, not the class’s own invariant templates:collect()->push($x)starts atCollection<never, never>and must widen per call. Supported writers and exceptions: the collection decision. - Before generalizing a template return, instrument a dump of what Psalm’s Populator actually stores (
template_extended_paramson a real fixture) instead of assuming the ancestry shape; plausible-looking code silently no-ops on the wrong storage keys. (Verified pattern in the #1287/#1295 fix chain.) - After fixing one member of a family, sweep the siblings by reusing the same resolver method, never by duplicating logic: the #1287 Builder gap repeated in Collections (#1295) and relations (#1294), each fixed by pointing at the shared resolver.
Tests
- A phpt exercising taint or an opt-in rule needs an
--ARGS--section before--FILE--: copy the matching block fromtests/Type/README.mdverbatim. Without it the test runs under plain defaults and a “Safe*” negative test passes vacuously; every new--ARGS--variant costs its own Psalm boot. - Write one comprehensive “must stay silent” phpt per rule enumerating every syntax shape that should defer, each line commented with the reason. A regression then names the exact shape that broke. (
tests/Type/tests/Relation/UndefinedModelRelationNoFalsePositivesTest.phpt.) - Name taint tests by outcome (
Safe*,Tainted*) and suffix a deliberately accepted soundness gap withKnownLimitation, its docblock pointing at the source class’s caveats section. Distinguishes reviewed trade-offs from untested holes. (tests/Type/tests/TaintAnalysis/SafeInlineValidateReassignmentKnownLimitation.phpt.) - Unit tests gate version-dependent behavior on a capability probe (
class_exists(...)or a behavioral check, thenmarkTestSkipped()), never a hardcoded version number; phpt files use--SKIPIF--withLaravelVersioninstead. (13+ gates inModelMetadataRegistryTestalone.) - Anything unobservable in-process (issue emission, STDERR writes, whole-project rules) forks a real
vendor/bin/psalmvia Symfony Process against a self-contained fixture:run()notmustRun()(Psalm exits non-zero on findings),--output-format=json --threads=1 --no-cache, and the fixture ships a private-namespacespl_autoload_register, never the package’s own autoloader. Each Psalm boot costs seconds, so add a scenario directory to the sharedEmissionAppfixture and assert against its one memoized run before creating a new fixture project. Embed the captured output in assertion failure messages so CI logs are self-sufficient. (EmissionAppTest,tests/Unit/Handlers/Fixtures/EmissionApp/autoload.php.) - Reset handler statics via
ReflectionPropertyinsetUp()/tearDown(), all interdependent statics together, or tests become order-dependent flakes. (CustomBuilderDetectionTest.) - Pair every positive branch assertion with a negative one proving the other branch’s output is absent; a regressed detector can satisfy a lone “contains” via its fallback path. (
InitCommandTest.) - Coverage is organized by testing layer, not by mirroring
src/: pure type-inference handlers (Cache, Collections, Config, …) have notests/Unit/Handlers/<Dir>and are covered by feature-named phpt files instead; PHPUnit dirs exist only for handlers with stateful internal logic.