Blade Analysis Internals

Implementation and behaviour reference for Blade template analysis: the pipeline, the shadow cache, template typing, contracts and read sets, the taint bridge, issue remapping and every known limit. The user guide is Blade Template Analysis. Design rationale lives in Architecture Decisions.

Pipeline and lifecycle

What is analyzed

  • Every *.blade.php file under the booted application’s view paths (config('view.paths')), plus every namespace hint a service provider registered with loadViewsFrom() (view('pkg::widget')), outside your Composer vendor directory.
  • Each template is compiled through the application’s own Blade compiler into a standalone PHP file (a “shadow”), and the shadow is what Psalm scans. The template itself is never handed to Psalm as PHP.
  • Each template is analyzed on its own. @include, @extends and component tags are not followed into the files they reference.
  • Issues found in the shadow are relocated onto the .blade.php path and the matching template line before they are reported. Nothing in the output points at the compiled shadow.
  • If discovery finds zero templates while Blade analysis is enabled, the plugin emits one warning, invisible under --no-progress, since Psalm’s own VoidProgress drops every warning in that mode.

Per-run switches (--blade, --no-blade), their precedence and cache handling: Per-run overrides. Template reporting configuration (<issueHandlers> per path, <ignoreFiles> warning): Suppressing and baselining.

Boot and registration

Behind <blade />, Plugin::initBladeAnalysis() compiles every template into a shadow (src/Blade/) and registers the result with the run. Blade\ContractParser reads @var/@props declarations from the template source using MarkerPrePass masking and nikic/php-parser. The step is synchronous inside __invoke on purpose: a file can only still join the analysis while Config::initializePlugins() is on the stack, which Psalm calls after queueing the project files and before scanning them.

The registrations are deliberately asymmetric (Blade\PsalmShadowRegistrar):

  • The shadow is added via Codebase::addFilesToAnalyze(), which both deep-scans and analyzes it. It must stay out of ProjectAnalyzer’s project-file list: TaintFlowGraph drops a flow whose source sits in a reportable file that suppresses TaintedInput. Psalm’s own addFilesToShowResults() is redundant here because Analyzer::addFilesToAnalyze() already writes the same map.
  • The template is written into ProjectAnalyzer::$project_files by reflection (Blade\ProjectFileInjector), because that private list is built from psalm.xml before plugins initialize and is all canReportIssues() reads. Without the write, nothing found in a template can be reported. The template is never queued for analysis: it is not PHP.
  • The ambient prelude classes (\Illuminate\View\Factory, ComponentAttributeBag, ComponentSlot, Component, Support\ViewErrorBag) are queued via Codebase::queueClassLikeForScanning(). Every shadow declares them as stacked one-line @var docblocks, and PhpParser attaches every stacked comment to one node’s comment list, so Node::getDocComment() (all Psalm’s scanner reads to queue docblock classes) returns only the last one. The last declared entry is $loop, an object shape naming no class, so left unqueued, all five ambient FQCNs report UndefinedDocblockClass the first time nothing else in the project names them in code position (#1494).
  • String-literal class candidates (Blade\ClassLiteralCollector) are queued via ShadowRegistrar::queueResolvableClassLikesForScanning(). A vendor directive can compile a class name into a PHP string argument (app('Vendor\Package\Class')::method()) instead of code position or a docblock, which neither Psalm’s scanner nor the ambient queue ever sees. BladeBootstrapper reads every shadow off disk (not the fresh ShadowResult, which does not exist on a warm-manifest run) after both other registrations, tokenizes it (T_CONSTANT_ENCAPSED_STRING only, so docblocks and interpolated strings are excluded), and hands the candidates to the Psalm-side adapter. The adapter gates each one on Config::getComposerFilePathForClassLike() or an already-declared class_exists($n, false)-family check before queueing it with store_failure=false. Most string literals name no class, so nothing is queued unconditionally here, unlike the ambient list (#1505).
  • Runtime helper files are queued whole via ShadowRegistrar::queueFilesForScanning() → Codebase\Scanner::addFileToDeepScan(), from the list ApplicationProvider::runtimeDeclaredFunctionFiles() captured around the boot. A package-style monorepo includes its helper file from a provider’s register(), so nothing in the project references it and Psalm would give it no FileStorage at all. This is one half of the #1551 fix; the other is Runtime helper visibility, and neither works alone.

The ProjectFileInjector guards (property_exists, is_array, catch (Throwable), and no setAccessible(), which is a no-op since PHP 8.1 and whose deprecation Psalm’s error handler promotes to an exception) keep a Psalm rename from crashing a run. Re-probe them on each Psalm release, not just each major.

A view name is always an array KEY, and PHP casts a numeric-string key to an int, so 123.blade.php reaches a string-typed consumer as int(123) under strict_types. AnnotationWriter casts its ViewReferenceRegistry::templates() key before looking up the contract. Cast the key the same way when touching the template registry.

Everything that reads a Psalm internal whose shape differs between Psalm 6 and Psalm 7 goes through Blade\PsalmBridge (TaintedInput detection, journey step shape, journey-text descriptor format), each method carrying the file:line it was read off in the Psalm this branch requires. Porting the pipeline to the other line is a rewrite of that one file; the 3.x copy additionally carries a taint-run probe, which Psalm 7 has no use for.

Handler registration

Registered from Plugin::registerHandlers(). $bladeActive is the flag initBladeAnalysis() returns to __invoke(): whether shadows actually joined the analysis, never config alone (see Degradation).

Handler Hooks Gate Role
Blade\RuntimeHelperVisibility AfterCodebasePopulated $bladeActive; init() runs in initBladeAnalysis() Runtime helper visibility
Blade\ComponentViewSeedHandler AfterCodebasePopulated, AfterStatementAnalysis $bladeActive Class component views
Blade\ViewTaintBridge AfterExpressionAnalysis, AfterStatementAnalysis $bladeActive, never for an annotate run; registered AFTER ComponentViewSeedHandler Taint bridge
Blade\BladeIssueRemapHandler BeforeAddIssue the compile step activated Issue remapping
Handlers\Views\ViewContractHandler AfterStatementAnalysis $bladeActive and the project’s own config enables Blade (a run where only the annotate codemod switched Blade on gets no view-data checks, matching the initMissingViewHandler() gate); no flag of its own Contracts and read sets
Blade\Annotate\AnnotationCollector, AnnotationWriter AfterStatementAnalysis, AfterAnalysis a valid annotate control file Annotation implementation

All three view-data issues (UnusedViewData included) are in ExperimentalIssuePolicy, so <experimental> and issueHandlers set their severity and nothing gates the walk (#1985). The compile pass always collects the read sets for the same reason.

Degradation

By default Blade analysis never fails a run. If the compiler or view finder cannot be resolved from the booted application (BladeBootstrapper::run() never runs with contracts silently empty), a view directory cannot be read and no template was found elsewhere, the cache directory cannot be written, or a Psalm internal the plugin depends on has changed shape, the feature turns itself off for that run and prints one warning naming the cause. The warning is mirrored to STDERR because Psalm’s --no-progress installs a progress implementation that discards warnings (Psalm also picks it by itself when an AI agent runs it outside CI). It is recorded in BladeBootstrapper::disabled(), which initBladeAnalysis() escalates to a thrown error under failOnInternalError (#1930), so a disable cannot pass CI silently. Finding no templates at all is not a disable.

vendor/bin/psalm-laravel diagnose prints whether your Psalm config enables Blade and where its shadows go: Blade: off or Blade: on (cache <dir>). It reads the config only, without compiling any template, so a disable shows up in the run’s warning, not there.

Degradation is all-or-nothing, which decides both the ordering inside run() and the handler gates (#1518):

  • The compile pass BUFFERS its template facts (contracts, data-include sets) on the bootstrapper instead of writing them to the registries as it goes, and publishTemplateFacts() replays them only once the run has activated. A degraded run reports nothing from Blade, and the view data checks stay silent.
  • The fallible registrar calls (markTemplatesReportable(), both scanner queues) all precede registerShadowsForAnalysis(), the one irreversible step. ShadowRegistry is filled in a finally around it, because remap entries only RELOCATE issues while ContractRegistry validates call sites and ViewReferenceRegistry drives annotation.
  • boot() returns whether shadows actually joined the analysis, initBladeAnalysis() passes that back to __invoke(), and every Blade handler registers on that flag.

A template that fails to compile (see Known limits), or whose declarations crash the plugin’s own parser, is skipped rather than aborting the run, and reported as a BladeCompileError error on its line 1 (Template not analyzed: <cause>) through ShadowRegistrar::reportCompileFailure(), after the template was made reportable: at the end of an activated run, or, when no template compiled, right after making only the failing templates reportable (nothing is analyzed and no template fact is published, so the all-or-nothing rule still holds). Being an issue, it survives --no-progress, fails the run, enters baselines and reaches <issueHandlers>. Other skipped templates (one that cannot be read, or whose shadow cannot be written), and compile failures that could not be made reportable, are summarized in one warning instead: up to three paths are named directly, and --debug lists every individual cause. BladeBootstrapper holds no static state, so it needs no entry in resetInvocationState().

Threads

Nothing in the pipeline needs a single process, and that is checked rather than assumed: a test compares a forked run against a single-process run of the same fixture, cold and warm cache, and requires identical issue lists. Shadows are compiled, and the registries the issue remap reads are filled, while Psalm initialises its plugins, which happens before either the scanner or the analyzer forks. Every worker therefore inherits a complete copy, and nothing writes to the shadow cache once analysis starts. A template issue found in a worker is re-emitted there against the .blade.php path and travels home in the worker pool’s result payload like any other issue. A taint finding is emitted later still, in the parent process, after the workers’ flow graphs have been merged, so its journey is rewritten where the registries live.

psalm-laravel blade:annotate is the one exception and keeps the analysis of the Psalm run it drives in one process (see Annotation implementation); --threads still sizes its scan. That restriction is about the producer types the command collects, not about the scan.

Shadow cache

cacheDir (default blade/ inside the plugin cache directory, resolved by PluginConfig::resolveBladeCacheDir(); absolute, or relative to the directory Psalm runs in) holds the compiled shadows and their manifest. The plugin creates the directory, reuses a shadow whose template has not changed, and deletes shadows whose template is gone. Each source generation gets a file named sha1($templatePath) . '-' . $fingerprint . '.php' (ShadowManifest::shadowPath()), alongside a single manifest.php that records, per shadow, the template path, line map, extends line, source fingerprint, inline suppressions and the template contract.

If you set cacheDir to a path inside your project, gitignore it and keep it out of <projectFiles>. A shadow that Psalm treats as one of your own project files gets analyzed twice, at the wrong location, and can make taint flows starting inside it disappear.

Invalidation

The source fingerprint hashes the template text, the marker pass version, the Laravel framework version, the plugin build (Internal\PluginBuildId: Composer version plus the installed commit reference) and (see Blade\CompilerEnvironment::describe()) the booted BladeCompiler’s own registration surface: a custom directive (Blade::directive()), an @if condition (Blade::if()), an extension, a precompiler, a string-preparation callback, an echo handler or format, the JSON-encoding options @json uses, a component alias or namespace (Blade::component(), Blade::anonymousComponentPath()), Blade::withoutComponentTags(), or a compiler subclass swap. Changing one of these invalidates every cached shadow once, even though no template file changed. Installing a different plugin version or commit (including a composer update of 4.x-dev) does the same.

When an input cannot be fingerprinted, the cache is skipped for that run and every template is recompiled: slower (about a second on a few hundred templates), but analysis results are unaffected, so there is no warning. Run with --debug to list every input responsible with its declaring file, e.g. directive 'x' (closure bound to App\Services\Foo) in app/Providers/AppServiceProvider.php. Typical causes: a callback bound to an object other than a service provider (an invokable object, [$service, 'method'], a closure created inside a service class), an internal function such as 'strtoupper', a captured object (use ($service)), or a BladeCompiler subclass. To restore caching for a callback, register a static fn / static function, or a static method ([Foo::class, 'bar']). Inputs declared under vendor/ (Livewire’s directives and precompilers, for example) are not yours to change.

What is NOT invalidated is runtime state read from inside a directive’s or condition’s own body rather than from the compiler’s registration surface: config(), a global, a class static property, or the instance state of a service provider (a plain Blade::directive() / Blade::if() closure registered in a provider is bound to it, and stays cacheable). The same is true of component metadata Laravel resolves live during compilation rather than storing on the compiler: ComponentTagCompiler reflects a class component’s CONSTRUCTOR PARAMETERS to decide whether an attribute becomes a constructor argument or stays an HTML attribute, and it resolves <x-foo> against the live view Factory DURING compilation, not at render time. Adding a constructor parameter to an existing component class, or adding a new anonymous-component template file, changes what an unrelated, already-compiled component tag COMPILES TO the next time its parent is recompiled, even though the parent template’s own source and the compiler environment are both unchanged. Its shadow stays fresh and keeps the old compiled bytes. Delete cacheDir by hand after a change like that.

The plugin build, which also keys the migration schema cache, does not see local plugin edits: Composer records the reference at install time, so the plugin’s own checkout and a symlinked path repository keep it until the next composer install or update, and uncommitted edits never change it. After editing src/ or stubs/ that way, clear the plugin cache directory (--clear-cache for the default location, or delete a custom cacheDir). --no-cache does not help: Psalm’s --no-cache unsets its own cache directory and the plugin then falls back to a persistent temp directory (PluginConfig::resolveCachePath()), so shadows stay warm.

Reclaiming and clearing

Delete the whole cacheDir to force every template to recompile. --clear-cache only removes $config->getCacheDirectory(), so it does the same for the default location, which nests under it, but not for a custom cacheDir set outside Psalm’s own cache directory. Pruning drops superseded manifest entries but retains their generation files because another invocation may still be reading them: clear the cache directory when no analyses are running to reclaim those files. Deleted-template pruning unlinks its files.

Concurrent runs on one cacheDir. Nothing locks the directory. Pruning a deleted template unlinks its shadow, which an overlapping run that read the same manifest may still hold in its scan list, and the superseded-generation retention above does not apply to it. When CompilerEnvironment::describe() reports an untrusted environment, compileAll() skips the freshness check and recompiles every template onto the same sha1($templatePath) . '-' . $fingerprint path, so overlapping runs rewrite one file under each other (the rename is atomic, so a reader sees one run’s complete bytes, never a torn file). A sound fix needs per-invocation shadow paths, and the line map and contract in the manifest would need the same split. Accepted residual: give concurrent runs their own cacheDir.

ShadowManifest::isFresh() takes an int-mask-of<ShadowManifest::SLOT_*>: a slot whose collection pass did not run when the entry was written (the plugin always runs it now, but BladeBootstrapper can skip it) is a null, and requiring it forces one recompile rather than leaving every template permanently “fresh with nothing collected”. ShadowManifest::normalizeEntries() drops any entry that is not seven slots wide, which self-evicts everything written by an earlier shape at the cost of one recompile.

Debugging a shadow

Run once so the cache is warm, then find the file by hashing the template’s real (absolute) path, not its project-relative path: findTemplates() registers each template by SplFileInfo::getRealPath(), and ShadowManifest::shadowPath() hashes that same string:

php -r "echo sha1(realpath('resources/views/profile.blade.php')), \"\n\";"

Open the <hash>-<fingerprint>.php referenced by manifest.php in the cacheDir directly: it is plain PHP, with the PreludeBuilder output as a leading docblock block followed by the compiled Blade output.

  • --debug lists every individual skip cause. Compiler and parser failures already report BladeCompileError on their own.
  • --threads=1 when stepping through BladeIssueRemapHandler or JourneyRemapper with var_dump(): forked worker processes swallow output. Clear the resolved cacheDir to force the compile path you are trying to observe; --no-cache alone does not clear it.

Template typing

Ambient variables

These template variables are typed automatically, in every compiled view, without any annotation:

Variable Type
$__env Illuminate\View\Factory
$errors Illuminate\Support\ViewErrorBag
$loop object{index: int, iteration: int, remaining: int\|null, count: int\|null, first: bool, last: bool\|null, odd: bool, even: bool, depth: int, parent: (stdClass&object{...same fields..., parent: object\|null})\|null}: $loop->parent is the enclosing loop with the same fields, null in an outermost loop

Two more are typed, but only inside a template the plugin recognises as a component view: one that itself writes @props(...), @aware(...), or mentions $attributes or $slot. A <x-*> tag’s caller never writes any of those, so the check is exact for that side. A component view with none of the four markers falls back to mixed for both names, silently, like any other undeclared variable. Only markers Blade actually compiles count: one inside a `` comment or an @verbatim body, or written as an escaped @@props/@@aware, is emitted as text or dropped, so it produces no $attributes initialisation and is no evidence the template is a component.

Variable Declared as Why
$attributes ?Illuminate\View\ComponentAttributeBag when the template writes @props(...), otherwise non-nullable when it writes @aware(...) or mentions $attributes without @props @props compiles to $attributes ??= new ComponentAttributeBag;, and only that branch can genuinely see the bag as absent. @aware assigns nothing, and Component::data() / AnonymousComponent::data() always supply the key otherwise, so both are typed non-nullable. Put @props first in the template: reading $attributes-> before it sees the nullable type. In a class component’s own view that is typed from its class, $attributes is non-nullable from the first line, @props or not: Component::data() always sets it on that path. A component view that itself renders a nested <x-...> tag gets a further re-assert of the SAME non-null type right after that tag’s compiled save/restore bookkeeping (#1543): both blocks reconcile $attributes to a spuriously nullable type otherwise, regardless of which of the three forms declared the view a component.
$slot Illuminate\View\ComponentSlot, whenever the template writes @props(...)/@aware(...) or mentions $slot Both render paths (a <x-*> tag and the classic @component('view', [...]) directive) hand the rendered view the same ComponentSlot (ManagesComponents::componentData()), so no union or path-specific typing is needed, and a template recognised by a directive gets it whether or not it names $slot itself. A bare $attributes mention does not pull $slot in: it is a guess over a name the template merely happens to use, too weak to declare a second name it never writes.

$component is not view data: Laravel never passes it (ManagesComponents::componentData() merges only the slot and the component’s own data). It exists as a local in the compiled output of a template that writes a <x-...> tag, whose save/restore around each tag reads it (if (isset($component)) { $__componentOriginal<hash> = $component; }, then restored after the render). When the template’s own source has no $component token, PreludeBuilder::tagBookkeepingTypes() declares it Illuminate\View\Component|null, nullable because a page renders outside any component, and gives each saved original the same type (#1873). $attributes gets Illuminate\View\ComponentAttributeBag|null the same way when the source has no $attributes token and the template is no @props/@aware view. The source is read as Blade rewrites it, so a token split by a `` comment counts. Any $component token keeps mixed, or no declaration at all when its first mention is a root-level assignment (see Known limits). A component view recognised only by its $slot mention gets the nullable $attributes too, sound but looser than runtime, where the bag is always set. (A class component with a public component() method is the exception, through its own view’s typing.)

Every other variable a template uses without a type the plugin can prove gets mixed, silently. No UndefinedGlobalVariable is raised for it, and no error tells you the variable went untyped by default (the Mixed* issues it would produce are not reported, see Mixed* issues), so a typo in a variable name is not caught this way.

A raw @var T $name docblock in a <?php or @php block types the template body as Psalm reads it: <?php /** @var \App\Models\User $user */ ?> followed by `` reports UndefinedMagicMethod/UndefinedMethod as it would in PHP, and a docblock naming a class that does not exist reports UndefinedDocblockClass on its own line. It overrides the mixed above from that point on. A docblock for one of the names above ($errors, $attributes, …) types it like any native annotation would, but is never a contract: Blade supplies those names (see Declaring view variables).

A @props([...]) entry whose default is a literal is typed from it, widened: a string is string, an integer int (a leading - included), a float float, true/false bool, and any [...] or array(...) expression array. @props(['size' => 'lg', 'count' => 0]) makes report `InvalidPropertyFetch`. The literal type is never used: a caller can pass any string for `$size`, so a `match ($size)` arm other than `'lg'` is not dead code. The type is also a claim about the default only: a caller passing another type is not checked against it, and a template guarding against one (`@if (is_array($size))`) reports `DocblockTypeContradiction`. These stay `mixed`: a bare name, `null`, every other default (a constant, a call, a variable, a concatenation), a name that is no assignable variable (`'max-width'`) or one Blade types itself (`$slot`, `$errors`), every prop a template's `@aware` also lists (whichever comes first, because `@aware` overwrites the default with the parent's value), and every prop of a [class component's own view](#class-component-views) (the class hands its data in before the default applies). Nothing is inferred in a template with an unreadable `@props` or `@aware` list (`@props($defaults)`, or a inside the list), which may set any name first. A raw <?php /** @var T $name */ ?> of the same name overrides the inferred type from that point on.

A local the template creates itself is the exception: a name whose first mention is a plain $x = ... statement at the template’s top level (in @php or raw PHP, not inside @if, a loop, @once, a <x-...> slot or any other block) that does not read $x is left undeclared, so Psalm types it from the assignment alone. A @var mixed declared ahead of it would make Psalm forget that a flag like $has = $x !== null guards $x once it reaches the next @if. An assignment inside a block may not run, and the view value it would have replaced (a @props default, a variable an including template passes down) is then the one read after it, so that name keeps its mixed declaration.

Class component views

The view a class component’s render() returns is typed from the component class when the component is rendered as <x-…> somewhere in the project (#1804). For a Component with public string $title, isActive(): bool and render() returning view('components.alert'), a template containing <x-alert title="Hi" /> seeds components/alert.blade.php as follows:

Variable Type
public property its declared type ($title: string); self and parent name the class declaring it, a trait property’s self the class using the trait, and static the component
public method Illuminate\View\InvokableComponentVariable<TValue> with no parameters, TValue being its return type ($isActive: InvokableComponentVariable<bool>), otherwise a Closure of its signature ($format: Closure(string): string). A generic method or class, or a method with neither parameters nor a return type, is mixed (not checked)
$attributes Illuminate\View\ComponentAttributeBag
$componentName string (Component::$componentName’s declared type)

To declare such a method in the template, write @var \Illuminate\View\InvokableComponentVariable<bool> $isActive: it passes the view data checks, and $isActive() is typed bool. A \Closure(): bool declaration reports InvalidViewData, because the value is not a Closure. The bare class name passes but leaves the call mixed.

The method closure is what Closure::fromCallable() returns at runtime: parameter and return types as Psalm reads them (docblock first), optional and variadic parameters kept, self and parent resolved against the class the method appears in (for a trait method, the class using the trait), static resolved to the component class, and the method’s own purity kept. The same type is what InvalidViewData compares against a template’s @var \Closure(...) declaration on the render() path, so a matching declaration passes and a mismatched one is reported.

These are exactly the keys Component::data() merges into the view on that path, with its precedence: a method wins a name a property also has. The view must be named literally, by a render() whose whole body is one return of view('name', …), view()->make('name', …), View::make('name', …), $this->view('name', …), or 'name'; an inherited render() counts. A userland $except only silences: the names it lists stay supplied but are mixed, because Laravel then keeps the value the call passes under that key. A default Psalm cannot read (a @var-typed or computed list) makes every property mixed.

A typed key is typed as present, so a defensive guard on it (``, @isset($title)) reports RedundantCondition / TypeDoesNotContainNull when the declared type is not nullable. Drop the guard: component data is always present.

Left mixed:

  • The whole view, when two component classes render it (through two render() methods or one inherited by both), another component’s render() outside the shape above names it too, no compiled <x-…> or @component(Foo::class) renders the class, or data() is not knowable: the class overrides data(), an extraction method (extractPublicProperties(), extractPublicMethods(), createVariableFromMethod(), ignoredMethods(), shouldIgnore()), or redeclares $except (an $except assigned at runtime, say in the constructor, is not seen: its names are still typed). It also stays mixed when any template renders the view by name (@include, @includeIf/When/Unless/First, @each, @extends, @component('name')), which hands it none of the data() keys. The data arguments must be literal arrays passed positionally: compact(…), a variable, named arguments, or a ->with() after the call decline too. An unqualified view() that a function of the component’s own namespace shadows, and a $this->view() the class overrides, do not count as the shapes above.
  • One key, when render()’s own data array passes it too (which value wins depends on how the view was rendered), any template passes a named slot of that name (<x-slot:title> overrides it), Blade sets it itself ($slot, $errors, $loop, $__env), the template declares it itself (a raw @var docblock, which wins), or the property has no declared type or belongs to a generic class.

Seeding mechanism

Blade\ComponentViewSeedHandler (AfterCodebasePopulated + AfterStatementAnalysis) types the view. The shadow bytes never depend on a class, so nothing about it touches the manifest or its fingerprint.

  • Boot. The same per-shadow tokenization as ClassLiteralCollector feeds Blade\ComponentTagCollector. It records the classes a compiled $component = Name::resolve( prologue renders (path A), every literal $__env->slot('name' name, and every literal inside a by-name render call ($__env->make|first|renderWhen|renderUnless|renderEach|startComponent(, i.e. @include, @each, @extends, @component('view')), which declines that view: such a render never runs data(). Both are buffered and published with the other template facts, so a degraded boot leaves Blade\ComponentViewRegistry empty.
  • AfterCodebasePopulated. ComponentViewRegistry::build() walks class storage for non-abstract Component subclasses, resolves the declaring render() through declaring_method_ids and Internal\Ast\ClassMethodResolver, and maps a literal view name to a template through ViewReferenceRegistry. A template claimed by more than one concrete class, or named by a render() outside the recognised shape, declines. Keys come from Handlers\Views\ComponentRenderData::guaranteedFor(), which shares its exposure walk with the producer-side forSource() but declines where forSource() may over-approximate ($except, ignoredMethods(), shouldIgnore()). Static, so reset in Plugin::resetInvocationState(); built pre-fork, so workers inherit it.
  • AfterStatementAnalysis. The seed lands on the prelude’s sentinel, the Stmt\Nop its closing tag parses to: the only statement on a line ShadowEntry::$lineMap sends to 0. Seeding on any later Nop would undo a template’s own reassignment; the badge fixture pins that. Psalm applies a statement’s @var docblocks before analyzing it, so the prelude’s @var mixed net is already in place and the seed overwrites it; anything earlier (BeforeStatementAnalysis, BeforeFileAnalysis) is clobbered by it. Names the template declares itself are left out at build time, which is what keeps a contract type winning.
  • Warm cache. A warm Psalm cache needs no reference recording on the CLI, which never runs in diff mode (Internal/Cli/Psalm.php, $is_diff = false): every shadow is re-analyzed against a registry rebuilt from current storage. The language server (diff_methods = true, ProjectAnalyzer.php:361) is not covered; see Architecture Decisions. ComponentViewTypingTest::a_warm_cache_follows_a_changed_property_type pins the CLI behaviour.
  • Types only. A seeded Union has no parent_nodes; ViewTaintBridge, registered afterward, attaches the property taint.
  • Component-view flag. A seeded shadow counts as a component view for ShadowTarget::$isComponentView (BladeIssueRemapHandler::resolveTarget()), so the relocator’s attributes|slot guard drop also covers a nested tag’s compiled isset($attributes) / instanceof bookkeeping in a view that never names $attributes. AttributesRestoreReassert keeps its source-only gate: it runs at compile time, before the registry exists, and such a view has no $attributes read for it to protect.

Livewire component views

The view a Livewire component’s render() returns is typed from the component (#1935), through the same ComponentViewRegistry::build() walk and claim set as class components. For a project Livewire\Component subclass with public Post $post and render() returning view('livewire.show-post'), livewire/show-post.blade.php is seeded as follows:

Variable Type
$this the component class
public property its declared type ($tags: list<string>), from FrameworkViewData::livewireProperties(): non-static, not declared on Livewire\Component itself, the same rule the view data checks use. A non-nullable object type with no default is T\|null with ignore_nullable_issues ($post: App\Models\Post\|null): Livewire passes one never initialized as null (BaseUtils), and the modal pattern assigns it later, in an event handler, so a guard ($post?->title, @if ($post)) stays silent while $post->typo() still reports. Explicitly nullable, scalar and array properties keep their declared type, strict nullable checks included

Livewire includes the view in a closure bound to the component (ExtendedCompilerEngine), so ComponentViewSeedHandler also sets Context::$self to it: protected and private members and self:: resolve as in the class. Only a Livewire seed holds $this (extract() never sets it, so a class component’s public method named this() is not seeded); seedFor()['this'] is that binding, with no second map.

Differences from a class component:

  • No tag needed. Livewire always calls render(), so the <x-…> gate does not apply. Only a render() declared in <projectFiles> is read, which also leaves out a Volt component (its class lives in a template, compiled into a shadow outside the project).
  • render()’s only return. Statements may precede it (a query into a local, say); then any other return in the body, a closure’s included, declines. The returned call has the class-component shapes; $this->view() and a fluent chain (->layout(), ->title()) decline.
  • render()’s keys subtract nothing. Livewire merges the public properties after render()’s data (HandleComponents), so a key never shadows a property, and the keys’ own values are not typed. Composer keys are not subtracted either, as for class components, although a composer runs at render time (View::renderContents()) and does replace a property of the same name; PR #1940 subtracted them, dropped here for simplicity. A creator runs when the view is made, before the properties are merged, so it never overrides one. Named slots are not consulted: a Livewire view is not tag-rendered.
  • No taint on the seeded variables. ViewTaintBridge attaches class-component property taint only, so $post carries none into the view. A $this->post read is an ordinary property fetch and carries Psalm’s own property taint.

Declines are the shared ones: a view a second class claims (two Livewire components, or a Livewire component and a class component), a rival render() outside the shape, and a by-name render (@include and the rest) leave the whole view mixed. A template’s own declaration wins per name, $this included: an author’s @var \App\Livewire\X $this keeps today’s behaviour (no binding, no drop below).

Typing $this exposes Livewire’s magic to Psalm 7’s sealed-by-default properties; see the UndefinedThisPropertyFetch drop in Compiler-generated code. Methods need nothing: Livewire’s __call() serves only macros, which MacroHandler declares when registered at boot, so $this->typo() still reports UndefinedMagicMethod.

Runtime helper visibility

Psalm keeps no global function table for ordinary project code: Functions::functionExists() answers from the ROOT file’s FileStorage::$declaring_function_ids, which a file gains entries in only by requireing the declaring file, transitively (Populator’s required_file_paths walk). A scanned function becomes globally visible only under allFunctionsGlobal, register_stub_files, or register_autoload_files. A shadow requires nothing and usually names no class, so every helper a package-style monorepo includes from a provider’s register() used to report UndefinedFunction … consider enabling the allFunctionsGlobal config option, and define()d constants in the same files reported UndefinedConstant (#1551).

Blade\RuntimeHelperVisibility (AfterCodebasePopulated) exposes the runtime-declared, non-vendor helpers and constants to shadows only, with their real signatures (wrong argument counts still report; a function nothing defines still reports). It merges those files’ declaring_function_ids and declaring_constants into every path ShadowRegistry::shadowPaths() knows. Ordinary PHP files are unchanged: Psalm’s project-wide allFunctionsGlobal remains the blunt escape hatch for those. Constraints:

  • +=, never a replace. A shadow’s own entries must win, and Functions::getStorage() throws UnexpectedValueException rather than declining when a declaring path does not carry the id, so a bad overwrite is a crash, not a false positive. For the same reason an id is merged only when the helper’s own FileStorage::$functions holds it, and a constant only when $constants does.
  • Intersect the boot’s declared NAMES (ApplicationProvider::runtimeDeclaredFunctionIds(), runtimeDeclaredConstants()) with the file’s storage, never take the whole file. A declaration behind a disabled feature flag or version gate, or nested in an uncalled function, is in that storage without the runtime ever declaring it; merging it would make an unreachable symbol resolvable in every template.
  • Shadows only. Merging into an ordinary project FileStorage would suppress genuine UndefinedFunction across the codebase.
  • Anything Functions::hasStubbedFunction() already answers is skipped, so a user helper never outranks a stub’s types, and the capture excludes vendor/ outright.
  • AfterCodebasePopulated, not earlier: FileStorageCacheProvider::writeToCache() runs during scanning, so a merge before that point would persist into the on-disk file-storage cache and leak into runs with Blade off. It runs in the parent before analyzeFiles() forks, so workers inherit the mutation by copy-on-write.
  • One capture per boot. The capture is the difference between get_defined_functions() / get_defined_constants() before and after doGetApp(), and ApplicationProvider::reset() clears it. A second boot in the same process (a language server, a test harness) finds the helper files already loaded (require_once declares nothing twice), so it captures nothing and the shadows report the helpers as undefined. Carrying the capture across reset() would break the contract that per-run facts never survive into the next invocation, and would queue another project’s helper files for scanning, so it is accepted. A file that only define()s constants is never found either: the files come from ReflectionFunction::getFileName(), and ReflectionConstant::getFileName() needs PHP 8.5 while the plugin supports 8.2. Such a file is exposed only through a function declared next to its constants.
  • A boot that degrades before providers register captures nothing, so the whole thing no-ops. A boot that throws mid-bootstrap keeps whatever helpers had already been declared: those functions exist in the process, and gating the capture on a fully clean boot would lose them in the one-bad-config-file scenario the bootstrap tolerance exists for (ApplicationProvider::runtimeDeclaredFunctionFiles() documents the same contract).

Typing of framework helpers

  • e() accepts \Stringable by design. Laravel’s e() has no type declaration on its $value parameter; htmlspecialchars() coerces it to string at runtime regardless of caller strictness, so passing a \Stringable is safe everywhere, not only at echo positions. A bool is accepted for the same reason (renders `'1'` or `''`). and an explicit e($stringable) call anywhere, including inside @php, do not report ImplicitToStringCast. Other sites are unchanged: a \Stringable passed to a plain function such as strlen(), or used in a concatenation under strict_binary_operands, still reports.
  • e() accepts the \Illuminate\Contracts\View\View contract (#1776). view(), Component::render() and ->with() are typed as the contract, which extends only Renderable, so `` (compiled to e($view)) would report InvalidArgument. The only framework implementation, \Illuminate\View\View, is Htmlable, so e() takes its toHtml() branch at runtime. Taint handling of e() is unchanged.
  • old() returns mixed for a key. Flashed input holds whatever reached the session: JSON scalars from $request->input(), any value passed to withInput(array), and a Model default’s attribute. Laravel’s own string|array|null is narrower than that, and $default accepts any value. Since the Mixed* family is not reported in templates, and report nothing.

Contracts and read sets

Declaring view variables

A template declares with raw @var docblocks in a <?php or @php block, and with @props([...]) entries. A @var line is read as PHPDoc reads it: the type is the first bracket-balanced token (array{id: int}, Collection<int, User>, also when it spans several lines), the name is the $identifier right after it, and anything after is a description, so a $ inside the description binds nothing. A raw @var counts only as a tag at the start of a line in a /** */ docblock that is a statement of the template’s own scope, anywhere in a <?php or @php block: the docblock of a property, method, function or closure, one inside a function body, and one inside an expression declare nothing.

A call site is checked against these declarations (MissingViewData, InvalidViewData). A namespaced name (view('pkg::widget'), @include('pkg::widget'), a mailable’s view('pkg::mail')) is checked against the template Laravel’s view finder resolves it to, as long as that template is analyzed; a namespace hint under your vendor directory, an unknown namespace, and a name the finder resolves to a non-Blade file are not checked. A / in a name counts as ., as it does for Laravel. Not checked either: a markdown('mail::…') (Laravel swaps the mail namespace for its markdown components when rendering it), and a name with surrounding spaces or a second ::. blade:annotate reads call sites the same way. Like every view data check, they are info by default and error under <experimental value="true" />.

  • Every declared variable is required, including T|null and mixed: a nullable type means “pass null explicitly”.
  • = null right after the name makes it optional: @var string|null $title = null. A call site may then omit the key, and a layout or partial that is rendered with and without a variable typically needs it. Write the type as T|null: Psalm reads the docblock as written, so @var string $x = null types the body string; it is reported as InvalidDocblock at the declaration (Blade\RawDeclarationHandler, from BladeBootstrapper::registerContract()’s problems), because body and call site would disagree. Text after the = null is still description. Tools that do not know the marker (bladestan) read the declaration without it and require the key; PhpStorm keeps the type.

A named slot is optional by nature: when the caller omits <x-slot:footer>, $footer is undefined in the component view. Declare it @var \Illuminate\View\ComponentSlot|null $footer = null and guard it with isset($footer). $slot itself is always passed. A ??, ??= or isset() guard on a required view variable reports as redundant: mark optional ones with = null. In a component view, the finding on a named slot declared \Illuminate\View\ComponentSlot ends with a hint naming the optional declaration (\Illuminate\View\ComponentSlot|null $footer = null). A view counts as a component view when it uses @props or @aware, mentions $slot or $attributes, or is a class component’s view.

Some names are never contracts, because no call site passes them: a name the template binds itself (a @foreach alias, an assignment target, a closure parameter, a catch variable), the names Blade supplies ($errors, $slot, $attributes, $loop, $component, $__env), data the framework shares into every view ($app and whatever a provider share()d before the plugin booted the application), and a @var in a plain /* */ or // comment, which Psalm does not read. They still count as declared for UnusedViewData. The missing-variable check also declines for a call site inside an Illuminate\View\Component subclass’s render() (its result can be chained by the caller), and for an unreadable @props or an open data set. A class name is compared against the call site’s value only when it is written with a leading backslash (\App\Models\User), or is a PHP internal class (Closure): a short or otherwise qualified name (Thing, Models\User) resolves through the template’s own use imports, so that variable is checked for presence only. blade:annotate writes class names with a leading backslash.

For a view with a composer or creator registered at boot (wildcards included), the keys it passes through literal $view->with() calls are checked for neither presence nor type, since it may overwrite them (a composer at render time, a creator inside view() before any chained ->with()); every other key is checked as usual. A composer the plugin cannot read (a closure, a non-literal key, $view used beyond with(), and the other shapes listed under composers and creators) turns both checks off for the whole view. Wildcard listeners registered straight on the dispatcher, as debugging and monitoring packages such as Ray, Telescope or Debugbar register them, are ignored; a variable such a listener adds is not seen, so declare it = null or suppress the issue.

Included templates

An include directive with a literal data array is checked like a view() call site, on the directive’s line:

  • @include, @includeIf, @extends('layout', [...]), @includeWhen($condition, 'view', [...]) and @includeUnless($condition, 'view', [...]) type-check the keys their array passes. A key the array omits is never reported: the included template also inherits the including template’s scope, so the key can still arrive.
  • @includeFirst(['override', 'view'], [...]) and @extendsFirst do the same against the first candidate that exists on disk, which is the one Laravel renders. The check is off when any candidate is not a plain string literal (a key, a variable, surrounding spaces), when a namespaced one comes before it, and when no candidate is found. A missing key is never reported for these, even without the including scope.
  • @includeIsolated('view', [...]) forwards no scope, so it is also checked for missing declared variables. @includeIsolated('view') passes no data at all, so every required variable is missing. The directive first ships in Laravel 12.47; earlier versions leave it uncompiled, so nothing is checked there.
  • @each and @component are not checked, nor is data passed in a variable (@include('view', $data)) or a view name that is not a string literal.

UnusedViewData does not judge includes.

Contract plumbing

  • Parsing. Blade\ContractParser::parseDeclarations() reads the raw @var docblocks and @props([...]) declarations of a template. The declarations are never fed into ShadowCompiler::compile(): a raw docblock already sits in the shadow body, where Psalm reads it itself. A line is split with Psalm’s own CommentAnalyzer::splitDocLine() (type = first balanced token, name = the $identifier after it, the rest a description), by one reader shared with ContractParser::rawDeclaredNames(). parseDeclarations() walks the compiled output only when the source contains a @var.
  • Registry. Blade\ContractRegistry maps view NAME to contract. BladeBootstrapper::compileAll() derives it from the template path relative to its view root (first root winning as FileViewFinder does) but only BUFFERS it; the registry is filled by publishTemplateFacts() after activation, in insertion order, so the first-root precedence still lives in register(). It is static because Psalm instantiates event handlers itself, as are ShadowRegistry and the other registries, so all are reset in Plugin::resetInvocationState(). publishTemplateFacts() strips framework-shared names (app plus Factory::getShared() keys) from every contract, and publishes the composer sources read at boot right after, through ContractRegistry::registerComposerSources().
  • Manifest. The contract is persisted as the manifest’s sixth slot, so a template fresh enough to skip recompiling still has one; normalizeContract() drops any contract that is not six wide, which recompiles an entry written by an earlier shape. The seventh slot holds the data-include set (see Read sets).
  • Composer keys (#1832). Blade\ComposerSourceReader::read() (boot) reads each contracted view’s composers and creators off the view factory’s Illuminate\Events\Dispatcher WITHOUT running them: the raw listeners of composing: {view} and creating: {view}, plus the wildcards property matched with Str::is(). A listener counts only when it is the closure Factory::buildClassEventCallback() builds (declaring file and line range, captured class and method), a wildcard one unwrapped from its addEventListener() closure first. A wildcard listener that is not that wrapper went straight to the dispatcher (a debugging package’s view watcher) and is skipped; anything else makes the view’s sources null (unknown). The class is taken as written: container bindings and factory or dispatcher subclasses are not modelled (accepted residual, see decisions.md). The classes are queued for scanning in the same queueClassLikesForScanning() call as the ambient classes, before the irreversible registerShadowsForAnalysis(). ViewContractHandler::afterCodebasePopulated() then runs Blade\ComposerKeyResolver, which resolves the method through declaring_method_ids and ClassMethodResolver (inherited and trait methods included) and walks its body: every occurrence of the first parameter must be the receiver of a literal-key with() chain whose result is discarded, or of name()/getName()/getPath(). Any other occurrence, a share(), a call to extract()/get_defined_vars()/func_get_args()/func_get_arg() or a compact() naming it or a non-literal name (they reach it by name or position), a variable variable, or a computed method or function name makes the keys null. Results live in ContractRegistry::composedKeys() ([] = no composer, null = unknown, whole view skipped); resolution happens before Psalm forks. Design record: Composer-supplied view keys are read from the composer’s AST.
  • Call chain. Handlers\Views\ViewCallChain walks the rendering expression from the OUTERMOST call inwards, collecting the view name and every data contribution in one pass. Outermost-first is the whole point: expression analysis is post-order, so a hook on the inner view('greeting') of a view('greeting')->with('name', $n) chain would report every declared variable as missing. It refuses on any link it does not model, and MissingViewData additionally needs the supplied key set to be provably closed. with() is read the way Laravel dispatches it, on is_array($key) rather than on the argument count, and the whole chain’s with() calls are deferred until the terminal receiver’s role is known, because SimpleMessage::with($line) on a MailMessage chain binds no template data at all.
  • Helper open set (#2079). ViewCallChain::from() marks the supplied set open (supplying([[], false]), no keys added) when the call site sits in a named function or a private / protected method: the caller can still finish the view with ->with(). It is the Component::render() open set applied by visibility; public methods and closures stay closed. A trait method is analyzed once, as the trait, so isInNonPublicHelper() reads the visibility its using classes give it (trait_visibility_map, a public alias under a new name in trait_alias_map, a trait using the trait followed to its users); a storage lookup that throws keeps the set closed. Only MissingViewData and AnnotationCollector read the open flag, so blade:annotate treats such a view as an open producer and declares no types from it.
  • Component data. Handlers\Views\ComponentRenderData adds what Illuminate\View\Component::data() merges in when the rendering expression sits inside a Component subclass: public non-static properties with their declared types, public methods (minus ignoredMethods()) as mixed, and the inherited $attributes. They land in ViewCallChain::$frameworkData, not $data: supplied() unions both for the declared-variable checks, $frameworkData winning a shared key because renderComponent() merges it last, while UnusedViewData keeps iterating $data alone, or every component render would report one finding per public method. $except and an overridden ignoredMethods() only REMOVE names, so ignoring them leaves the set a superset, the safe direction; an overridden data() can ADD them, so it opens the set instead.
  • Emission. PreludeBuilder::BLADE_OWNED_NAMES is re-checked on the PASSED side, because the read-set extraction strips those names as Blade’s own. It is deliberately the full six-name set (__env, errors, loop, attributes, slot, component), not AMBIENT_TYPES (the three names the prelude ALWAYS types): a call site passing 'slot' is never wrong about the template, even for a template where attributes/slot/component are typed conditionally or not at all (#1525, #1873). The check is deliberately not gated on ViewCallChain::$complete: an open key set means there may be more keys, not that the ones in hand were not passed.

View data checks

Recognized call shapes: the view() helper, Factory::make() and its View facade forms, response()->view(), Mailable::view() / markdown(), MailMessage’s equivalents, and any number of with() / withErrors() calls chained on top of them. The whole chain is read at once, so view('profile')->with('name', $n) is checked against the data the chain supplies in total, not against the empty data of its inner call.

Beyond the call’s own arguments, three receivers supply keys at render time. They land in ViewCallChain::$frameworkData (supplied, never passed, so UnusedViewData cannot see them) and FrameworkViewData derives them from Psalm storage alone, so no class is loaded and Livewire need not be installed:

  • Mailable: Mailable::buildViewData() merges $viewData, then the buildViewDataUsing() callback, then every initialized public property whose declaring class is not Mailable itself (statics included, as ReflectionClass::getProperties(IS_PUBLIC) returns them), so a property overrides a with() key. The class read is the RECEIVER’s type ($order->view(...) from outside included); $this is the enclosing class. Mailer::send() adds $message and mailer; additionalMessageData() adds __laravel_mailable. A MailMessage chain also gets mailer and the __laravel_notification* keys (MailChannel::additionalMessageData()). Those keys are mixed; $message is typed per path (below). An abstract Mailable leaves the set open, as a concrete subclass can add public properties.
  • MailMessage: MailMessage::data() is array_merge(toArray(), viewData), so the eight SimpleMessage keys, displayableActionUrl and $message count, and a key the call passes wins (the opposite of supplied(), so the call’s keys are left out of the framework set). A passed set that is not fully known drops the toArray() keys. The eight property keys are mixed: the vendor docblocks (array, string) are coarser than the values and wrong for the null defaults, so typing them would reject valid declarations such as list<string> $introLines. Only the computed displayableActionUrl is a string.
  • Livewire: ancestry only (livewire\component in parent_classes), inside a method named render(), and only for the chain that is the returned view: the view() helper or a view factory (View::make()), never a Mailable or MailMessage view built on the side in render() (ViewCallChain::from() checks the chain’s binder). Rule, from livewire/livewire src/Drawer/BaseUtils.php (getPublicPropertiesDefinedOnSubclass()) and HandleComponents::getView(): public, non-static properties not declared by Livewire\Component / Livewire\Volt\Component, applied with ->with() after render() returned. Stays complete: Livewire calls render() itself, unless the component is abstract, as a concrete subclass can declare more properties.

Types are the declared property types of a Mailable or Livewire class (expanded against the class). These are mixed, which InvalidViewData skips: an untyped property, a generic declaring class, and a Mailable property the call also passes by name (an uninitialized typed property is skipped at runtime and the passed value survives). $message is typed by FrameworkViewData::messageKey() per path, from vendor Mailer::send()/render(), Mailable::buildMarkdownHtml()/buildMarkdownText() and MailChannel::buildMarkdownHtml()/buildMarkdownText(): on view() the Mailer sets it after every merge, so it is Illuminate\Mail\Message whatever a property, a passed key or an extraction override (buildViewData(), additionalMessageData(), data(), toArray()) says. On markdown() the data is merged over it, so a property named message keeps its type, a passed message adds no framework key (the passed type is checked as passed), and otherwise it is mixed when the data is not fully known or an override is present. A Mailable’s text pass first wraps it in Illuminate\Mail\TextMessage (@mixin Message, not a subclass), so the remaining Mailable case is Message|TextMessage; MailChannel does not wrap, so a MailMessage gets Message. An unresolvable declaring class, a base Mailable / contract / union / intersection receiver, an abstract Mailable or Livewire class, and a MailMessage subclass overriding data() or toArray() make the chain incomplete, so nothing is reported missing. A Mailable overriding buildViewData() or additionalMessageData() does too, and every property key and passed key is mixed (a passed key stays passed, so UnusedViewData still judges it). self and parent in a property type name the class declaring the property (for a trait’s property, the class the trait appears in; none resolvable means mixed); static is the rendered class.

Accepted imprecisions. Silent (a superset of the real keys): an uninitialized typed Mailable property counts as supplied. Accepted, in the reportable direction: an uninitialized typed Livewire property renders as null ([] for an array) at runtime, but keeps its declared type here, because widening every such property to T|null would reject the common public Order $order; assigned in mount(). Still reportable, because the keys are not modeled: a public property a Mailable subclass adds beyond the receiver’s declared type, keys from the Mailable::buildViewDataUsing() callback, and Livewire #[Computed] properties. Not recognized call sites: new Content(view:, with:) and $this->with() in a separate statement.

Gates. The per-issue pages list every gate. Both checks decline for a dynamic view name and for a chain containing a method the plugin does not model. An open data set (a spread, a dynamic key, $mergeData) and an unreadable @props array gate only MissingViewData; the keys the call does pass are still type-checked. A mixed on either side skips the type check of that key (InvalidViewData) and does not silence MissingViewData. A template that declares nothing is never checked.

Read sets and unused view data

UnusedViewData reports a data key a call site passes that the rendered template neither reads nor declares. Both declaration forms count (Declaring view variables): @props, and a raw @var T $name docblock. What each template reads (from its compiled output) and what it declares are taken together, then closed over its @include / @extends chain, since those directives inherit the including template’s whole scope; @includeIsolated and @each do not, so they never count a key as consumed.

The check declines for a call site instead of guessing:

  • Unknown reads (@props, @aware, extract() or a dynamic compact()) and a dynamic @include($name) at any depth disable the check for that call site alone, not for the run. A template whose compiled body hides which names it reads is never checked, which leaves component templates (@props and @aware compile to $$name writes) outside the check’s reach. The chain is followed as far as every include in it names a literal template; a key a template reached through @include or @extends reads or declares counts as consumed.
  • A whole-scope call outside the recognised $__env include methods (#2209: BookStack’s Blade::directive('include', ...) compiles to $__themeViews->handleViewInclude(...)) may render any view, so it declines the chain like a dynamic @include($name). Only the exact array_diff_key(get_defined_vars(), ...) shape counts, so Livewire’s wire:key and Filament’s @capture (a bare get_defined_vars()) keep their coverage. The issue page lists the remaining gates.

Implementation:

  • Read set. ContractParser::parseDataContract() is parseDeclarations() plus a walk of the COMPILED output. It is a side channel: the contract is built after ShadowCompiler::compile() returns and never reaches it. One walkReads() produces the reads. The read set retains locally bound names because UnusedViewData asks whether a name is used at all; the annotation writer separately excludes local bindings (see Annotation implementation). A raw @var T $name docblock (any <?php or @php block, found through MarkerPrePass::maskedRanges()) becomes a ContractVar via rawContractVars(), minus the names walkReads() reports as locally bound (a type hint on a @foreach alias or an assignment target is not view data) and PreludeBuilder::BLADE_OWNED_NAMES. Only a T_DOC_COMMENT counts, and optional is set only by a description starting = null (splitVar()): nullable and mixed types are required. A @props entry wins a name collision with a raw docblock. ContractParser::withNull() widens an optional type to T|null for the InvalidViewData comparison and the annotator’s T|null $x = null output. What does not become a ContractVar (a local) still rides in ViewDataContract::$rawDeclaredVariables, consumed-only: ReadSetResolver and the writer count it as declared.
  • readsUnknown is load-bearing. A parse failure, a $$name, an extract(), or a compact() with a non-literal argument makes the set a lower bound, and a lower bound cannot prove a key unused. get_defined_vars() is deliberately NOT in that list: Blade compiles it into every @include, so treating it as unknown would disable the rule almost everywhere.
  • Data includes. ViewReferenceCollector::collectDataIncludes() collects the manifest’s seventh slot: the subset of a template’s references that inherit its whole scope. Membership is decided by the DATA argument, not the directive: every scope-passing directive compiles it to array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]), whose position shifts with the directive’s own optional data array, so the whole argument list of every call-like node is scanned for that shape. @includeIsolated and @each compile to the same $__env-> methods but pass no parent scope and are excluded by that gate alone. A whole-scope call other than $__env->make(), first(), renderWhen() or renderUnless() sets the dynamic flag.
  • Closure. Blade\ReadSetResolver closes each template’s reads UNION its declared contract vars over that graph, recursively, with a by-reference visited set (a revisit contributes nothing, which is correct for a union and terminates on a cycle). It declines on the first template in the chain whose reads are unknown, whose data-includes hold a dynamic name, or that ContractRegistry never claimed.

Taint bridge

Blade\ViewTaintBridge (AfterExpressionAnalysis + AfterStatementAnalysis; #2031, #2032, #2033) is the one file that owns the store naming and every Codebase taint call (getTaintNode, addTaintStoreWrite, addTaintStoreRead), so Psalm 7’s keyed taint stores (vimeo/psalm#12312) stay behind one seam. Shadow text, variable types, contracts and the shadow cache key do not change. It runs whenever Blade analysis is on, independent of the experimental view-data issues.

  • Store layout. One store per canonical view name, Illuminate\View\View::$data@{view}, keyed by variable name with an exact name. Store nodes carry no location, so a write recorded in a controller’s worker and a read recorded in a shadow’s worker are the same node once the parent merges the worker graphs; no state crosses a process boundary.
  • Source gating, cheapest first. afterExpressionAnalysis checks the call name (view/make/with, plus first/renderwhen/renderunless/resolve only inside a shadow, because they are common method names), then tracksTaintFlow(), then ViewCallChain::from($call, $source, trimName: true) resolving the receiver role (view() helper that resolves to the global function, view factory, response factory; mail roles decline). The receiver role of a userland subclass counts only while the called method is still declared by a Laravel class.
  • Sink. The prelude’s sentinel Nop, found the same way as ComponentViewSeedHandler does. For each variable in scope except $__env, $__path and $__data (ViewTaintBridge::NEVER_VIEW_DATA), a read node $q (view data of {view}) is attached to the variable’s type, reading both the exact name q and the whole-array name * (path arrayvalue-fetch-'q'), once per name the template owns (ViewReferenceRegistry::namesFor(); a published override owns two).
  • Order. The bridge is registered AFTER ComponentViewSeedHandler, whose seed replaces a variable’s whole type and would otherwise drop the attached node. BladeViewDataTaintTest pins it with a class component’s @props view.

Taint through view data

A value a controller passes to view() reaches the template as tainted view data (user guide: Taint through view data).

  • Keys are the unit. ViewCallChain::ownEntries() returns only the literal entries THIS call passes ($data, plus $mergeData for the helper and the view factory, last key wins). Each literal key is its own store write, so ['t' => $input] does not taint $q: a template variable is tainted only through the keys passed, never through the whole array.
  • Each call contributes its own arguments. response(view('x', [...])) and view('x')->with('q', $input) both work because every link of a chain writes what it passes; the bridge never walks statements.
  • An array that is not a literal is one whole write. ViewCallChain::ownArrays() returns the data expressions that are not literals (a variable, compact(), a with() key that may be an array). view('x', $data), ->with($array), $mergeData held in a variable and compact('q', 't') write the array under the store name * (a variable name is never *); the sink reads * with the path arrayvalue-fetch-'q', so Psalm’s own key paths keep ['q' => $input, 't' => 'lit'] from tainting $t. An array Psalm knows nothing about ($request->all()) fetches any key. A literal array with a non-literal spread ([...$extra]) is still written key by key and drops the spread (see “Skipped shapes”).
  • Compiled includes write in a shadow. $__env->make(), renderWhen(), renderUnless() and first() resolve through ViewCallChain::includedViews() (receiver resolved to the view factory, so a look-alike make() on another class never counts) and write the passed keys and non-literal data like a PHP call. renderWhen(false, ...) and renderUnless(true, ...) render nothing, so a condition Psalm types as always falsy (renderWhen) or always truthy (renderUnless) writes neither data nor scope. For first() the bridge writes every literal candidate up to the first one known to exist, skipping those provably absent: a namespaced candidate has unknown existence, so it is written and the walk goes on (taint-only; the view-data checks keep firstExistingCandidate()).
  • The includer’s scope is every variable in scope. When an include call carries the compiled scope argument, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]) in the $data or $mergeData position (exactly those two keys: an author’s own filter such as ['q' => 1] is not it), every plain $name in Context::$vars_in_scope that carries a taint node is written under its own name ($a['b'] and $a->b context ids are skipped). @includeIsolated has no such argument. Gating on the shape, inside a shadow, keeps a hand-written look-alike in a PHP file from inheriting its local variables; inside a shadow the same code would inherit them, so it counts. Over-approximation: an explicitly passed key and the scope write the same name, and the store holds the union, so @include('p', ['q' => 'safe']) still reports in p when the includer’s own $q is tainted, although the explicit data wins at run time.
  • Anonymous components write the data of AnonymousComponent::resolve(). The compiled call is resolve(['view' => 'components.x', 'data' => [...]] + $outerAttributes); the bridge reads the literal view and data entries of the left operand, in a shadow only. The tag also calls withAttributes([...sanitizeComponentAttribute($value)]), which routes the value through e(): that copy is not written, so `` stays clean (a raw {!! $attributes->get('q') !!} is a missed flow).
  • A literal key that is not a variable name is never written. extract() skips *, 'a-b' or '0', so such a key never reaches a template, and * would collide with the store name of a whole array.
  • Every name but the engine’s own takes view data. extract($__data, EXTR_SKIP) keeps only $__path and $__data, and View::gatherData() merges a view’s own data after the shared values, so ['errors' => $input] or ['__q' => $input] reaches the template. The sink skips $__path, $__data and $__env (the factory every compiled directive calls); a name the compiled code assigns before reading it ($loop, $__currentLoopData) carries no read node past that assignment.
  • Only / → . normalisation is shared with Laravel. ViewName::normalize() turns / into . and nothing else, while the finder still resolves a leading slash (/search), a doubled one (admin//panel) and, on a case-insensitive filesystem, another case (SEARCH). Such a call writes under a name (.search) no template reads, so the flow is missed.
  • $mergeData counts, and the last write of a key wins. view('x', [], ['q' => $input]), View::make('x', mergeData: [...]) and $factory->make(...) write the keys of their merge array too (response()->view() has none: its third argument is the status). Inside one call $data wins $mergeData, as Factory::make() merges them, and inside one array literal a later key, including a key of a spread array literal, wins the earlier one, so ['q' => $input, ...['q' => 'safe']] writes nothing. A spread or key that is not literal never drops an earlier write.
  • Only Laravel’s own binder bridges. The view() call must resolve to the global helper (a use function import or a same-named function in the current namespace declines), and a method called on a userland subclass of the view or response factory counts only while no userland class overrides it: an override can render another view (parent::make('other', $data)) or return an object whose with() ignores the data.
  • A padded literal name binds. view(' search ', ...) renders search, because FileViewFinder::find() trims the name, so the bridge trims it. The view-data checks keep declining an untrimmed name.
  • Skipped shapes (a template fed only that way reports nothing): a dynamic view name, View::share(), composers, Route::view(), Livewire, mail, a spread of a non-literal inside a literal data array, a with($key, $value) whose key is not a literal, and a with() on a view held in a variable ($v = view('x'); $v->with('q', $input)): the receiver is a variable, so the call names no view.
  • Not bridged: bag entries and @aware. An anonymous component given :attributes="new ComponentAttributeBag([...])" gets the bag’s entries promoted to view variables at run time, and @aware pulls values from an ancestor component’s data; neither is a literal data entry of the compiled resolve() call.
  • Over-report: a later link or statement that overwrites a key. view('x', ['q' => $input])->with('q', 'safe') still reports: each link writes only its own arguments and the inner link cannot see an outer one, so the store holds the union of every write for a key (taint over-approximates; it never hides a real flow). The same holds for $v = view('x', ['q' => $input]); $v->with('q', 'safe');.
  • Over-report: a non-literal $mergeData. view('p', ['q' => 'safe'], $merge) writes $merge whole, so a tainted $merge['q'] reports in p although $data wins at run time. It is the same class as the include scope above.
  • No read node for a variable the prelude never names. The sink attaches to the variables in scope at the prelude’s sentinel, and the prelude declares only the names the compiled template mentions as a $name token. compact('q'), $$name, extract(), get_defined_vars() and a bare @php /** @var string $q */ @endphp introduce $q without that token, so such a template reports no TaintedHtml for it. The same limit cuts a relay: an includer that never mentions $q has no $q in scope, so request input it received as view data does not reach a partial that echoes $q. Covering them needs the prelude (shadow text and cache key) to declare names it cannot see.

Taint through class component properties

A value given to a class component reaches the component’s own view as tainted data (user guide: Taint through class component properties). Blade\ViewTaintBridge owns both halves, next to the view-data bridge.

  • Source: the compiled Foo::resolve([...] + $attributes). <x-foo> compiles to it and @component(Foo::class, ...) to Foo::class::resolve(...) (closed with @endComponentClass; @endcomponent leaves an if open that fails to parse). resolve is a template-only call name, so a hand-written Foo::resolve() in PHP code is not followed, nor is $container->make(Foo::class, [...]). The class must be a Component subclass other than AnonymousComponent, which keeps its own branch. Only the literal left operand of the + is read.
  • Keys bind constructor parameters, not properties. resolve() does new static(...array_intersect_key($data, array_flip($parameters))), so each entry is connected to DataFlowNode::getForMethodArgument() of the declaring constructor’s parameter (Psalm’s own node for new Foo($input), which therefore needs no write). The node is keyed by the constructor’s body method id, which for a constructor declared in a trait is the using class, because Psalm analyzes a trait body as the using class’s own (FunctionLikeAnalyzer::getCasedBodyMethodId(), no public API, #1978). The constructor body does the rest: e($message), a replaced value and $this->text = $label behave as for new. Writing straight to the property node would bypass constructor sanitization.
  • Sink: property nodes at the prelude sentinel. ComponentViewRegistry::propertyIdsFor($shadowPath) lists, per variable, the property nodes of every class whose render() returns the view’s literal name (contested or not, whatever data render() passes), built at AfterCodebasePopulated from ComponentRenderData::exposedPropertyIds(). A read node $name (component property) gets a path from each and joins the variable’s view-data reads. SLOT_OWNED names are left out: componentData() overwrites $slot with the rendered slot.
  • The node is the class Psalm writes. The declaring class, or for a trait property the farthest class of the hierarchy using the trait; Psalm links no subclass id to an inherited property node, and a trait’s own node is never written. A literal $except default drops a name (read from the property’s suggested_type).
  • Over-reports. The node has no instance or call site, so one tainted tag taints every read in that class’s view. A property of a shared base class is one node for every subclass. A constructor declared in a trait is shared the same way: the assignment node behind the trait body’s parameter is one for every class using the trait, so a tainted tag of one component reports the raw echoes of every other component using it. This is Psalm’s own trait body behavior (a plain new A($_GET['m']) followed by echo (new B('safe'))->message reports it without this plugin), so the fix layer is upstream. A public method sharing a property’s name (it wins in data()), a non-literal $except, and an overridden data() or resolve() are not detected.

Issue remapping

Neither registration makes a finding visible by itself: an issue Psalm raises in a shadow is located in a file that is deliberately unreportable, so it dies in the reportability gate. Blade\BladeIssueRemapHandler (BeforeAddIssueInterface) is the only channel from shadow analysis to user-visible output.

  • Blade\ShadowRegistry maps shadow path to template path, line map and per-template-line suppressions. BladeBootstrapper fills it for every shadow it registers, including templates fresh enough to skip recompiling (those facts come off the manifest).
  • On a registry hit the handler rebuilds the issue on the .blade.php path (Blade\ShadowIssueRelocator), re-emits it through IssueBuffer::accepts(), and returns false to kill the shadow-path original. BeforeAddIssue is the only hook that can do this: Psalm dispatches it before both the reportability gate and IssueBuffer::isSuppressed().
  • The rebuild walks the constructor reflectively. 97 of Psalm 7’s 320 concrete issue classes declare a required third parameter, so new $class($message, $location) throws for them, and inside an event handler that is a finding lost without a trace. A parameter that cannot be resolved declines with null (Psalm keeps the shadow-path original, invisible but not swallowed) rather than dropping the issue.
  • accepts() is handed the suppressions recorded for the mapped template line, which is what makes `` work: the event carries the issue but not the suppressed-issue list Psalm was about to check it against. Template findings go through Psalm’s per-path <issueHandlers> with the .blade.php path, so a <directory name="resources/views" /> entry applies to a template line exactly as to a PHP file, with nothing extra, because accepts() consults Config::reportIssueInFile() on the new path itself (tests/Unit/Blade/BladeIssueRemapTest.php).
  • A shadow line that maps to no template line (the prelude) is re-emitted on line 1 with an ` (unmapped) suffix. Mixed* issues are dropped instead, because the prelude types every unresolved template variable as mixed and those findings say nothing about the template. Any positionless issue (line -1, from a node Psalm synthesized without attributes) is declined, so Psalm drops it as it does on plain PHP. UndefinedThisPropertyAssignment is the deliberate exception: plain PHP reports nothing for it on a receiver such as SealedMagic::make()->missing = 1`, but the plugin keeps the #1545 line-1 finding as a template-only diagnostic.
  • Some Psalm messages embed a shadow position of their own, and ShadowIssueRelocator::remapMessage() rewrites them through the same ShadowTarget::templateLineFor() the location uses. first seen on line N (PossiblyUndefinedGlobalVariable, PossiblyUndefinedVariable) and php-parser’s ` on line N suffix on ParseError are matched by exact issue class and end-anchored, so no other message is touched. A reference into the prelude drops that clause instead of clamping, because in a message a clamped number reads as a claim about line 1. The file:line:column descriptor of the issue's own shadow (ReferenceReusedFromConfusingScope) goes through JourneyRemapper::rewriteLocationSummaries()`, the same substitution the journey text uses, so it clamps a prelude line to 1 like a journey step does.
  • A taint flow graph is not a reason to decline: Psalm 7 runs taint by default and emits type and taint issues from the same run, so both kinds have to be relocated. (The 3.x pipeline does decline there, because Psalm 6 runs taint exclusively and IssueBuffer::add() discards non-Tainted* issues anyway.)
  • A TaintedInput carries two further constructor arguments describing how the taint travelled, and Blade\JourneyRemapper remaps both before the rebuild. The journey ARRAY holds the source chain, each step repositioned onto the template its shadow came from; the journey TEXT also spells out the sink-side hops the array stops short of, so it is rewritten by substituting file:line:column descriptors rather than regenerated. A journey crosses files, so the remap resolves a shadow per step (and for the issue’s own path) instead of reusing one; steps in ordinary application code are left untouched. A shadow step whose template has no such line declines the whole issue with null, because a half-remapped journey is worse than none.

What is reported

One psalm run reports both kinds, for templates exactly as for PHP (Psalm 7 runs taint analysis by default).

  • Type analysis: the same issue types Psalm reports anywhere else, at the template’s file and line, except Psalm’s MixedIssue family (MixedArgument, MixedAssignment, and the rest), which is never reported in templates because an undeclared template variable typing as mixed produces it constantly. See Mixed* issues.
  • Taint analysis: TaintedHtml on unescaped {!! !!} output that traces back to request input, with the whole trace shown against template lines, never against the compiled shadow. Escaped `` output of the same tainted value is not flagged. @json($value) is not flagged either: its default flags are all four HEX flags. JSON_HEX_TAG with JSON_HEX_AMP clears TaintedHtml, and JSON_HEX_APOS, JSON_HEX_QUOT and JSON_HEX_AMP clear TaintedTextWithQuotes. Your own flags keep each finding whose flags are missing: @json($value, JSON_HEX_TAG | JSON_HEX_AMP) still reports TaintedTextWithQuotes, and flags without JSON_HEX_TAG or JSON_HEX_AMP report TaintedHtml. Keep the directive’s arguments comma free: Blade splits them on every comma. Known gap: the encoder’s own " delimiters close a double-quoted attribute, so <div data-x="@json($value)"> is an unreported breakout. It renders visibly broken for every string, array or object value, but an int, bool or null encodes bare and renders fine. Use a single-quoted attribute, a <script> block, or @js($value) (a complete JavaScript expression, never wrapped in quotes) there. See Security.
  • Positions inside a message name the template. The first seen on line 7 of Possibly undefined variable $x, the trailing on line 7 of a ParseError, and the file:line:column of a reference’s definition in a ReferenceReusedFromConfusingScope all count the template, not the compiled shadow. A first seen reference into compiler-generated code has no template line, so that clause is omitted.
  • Issue-level policy. RiskyTruthyFalsyComparison keeps the same default as PHP files: the rule fires just as often in the same project’s plain PHP, so a template-only downgrade would make one rule behave two ways in the same codebase. On the reference corpus about 68% of the template hits were harmless optional-field guards, but the remainder included real zero-value bugs, such as a rating of 0 rendering as “not rated”. PossiblyFalseArgument on a json_encode() result passed to an author-written call (for example ) is deliberate signal: the false arm is a silent encode failure, so the fix is `JSON_THROW_ON_ERROR`, not suppression. `@json($x)` shares the silent failure rather than fixing it, and when the compiler itself wraps the value ( compiles to echo e(json_encode($x))), the finding is already dropped as unactionable in echo position.

Compiler-generated code

livewire/blaze is switched off rather than gated. Its earliest string preparation callback rewrites every <x-...> tag into a require of its own compiled function (with an unchecked ltrim(ob_get_clean()) and slot stacks around it) and wraps a Blaze component view in an untyped function, which reported UnresolvableInclude, MissingParamType, PossiblyFalseArgument and PossiblyUndefinedGlobalVariable on nearly every tag line. BladeBootstrapper::disableBlaze() sets blaze.enabled and blaze.debug to false, calls BlazeManager::disable() and resets the manager’s cached debug flag (Blaze reads it at boot, so BLAZE_DEBUG=true would otherwise keep instrumenting every tag) before any shadow compiles. The callback then returns its input and Blade compiles the tags as it does without Blaze, Blaze’s own enabled = false render path. Residual: Blaze’s compile time validation (for example the InvalidBlazeFoldUsageException a folded component using @csrf throws) never runs, so a template Blaze would reject at compile time is analyzed as plain Blade.

A registered Blade precompiler (Livewire’s component tags are the common case, <livewire:x />) can rewrite a template into PHP the author never wrote and has no position in the source to annotate. Several issue families are dropped at shadow emission rather than relocated to the template:

  • MissingClosureParamType and MissingClosureReturnType, unconditionally. Precompiler-injected closures dominate this family inside a shadow, and an author has no way to annotate one. The drop also suppresses missing-type warnings in author-written closures: a closure written by hand inside @php or a raw PHP block can carry native parameter types and a docblock, and its warning is silenced along with the generated noise. Accepted because it is a signature warning on code nothing outside the template ever calls.
  • MissingPureAnnotation on a closure, unconditionally, with the same trade-off (#1908). Livewire’s @livewire and <livewire:...> compile to a $__split closure that draws it on every tag.
  • InvalidMethodCall on a string literal receiver (#1908), only when the call, receiver included, does not occur in the raw template source. @entangle('tab') compiles to 'tab'->value() and 'tab'->hasModifier('live'), text the template never holds, while an author’s own x keeps reporting.
  • TooManyArguments, only when the call expression the issue points at does not occur anywhere in the raw template source. Blade rewrites the lines it compiles (becomes an `echo e(...)` statement), so the comparison is the call itself, cut out of the compiled line at the issue's own position, rather than the compiled line. An author's over-arity call therefore survives inside, {!! !!}, @php and a raw PHP block alike, because Blade wraps around a call expression rather than rewriting its interior. The one thing Blade does change inside a call is ``: comments are removed before anything else compiles, so they are removed from the template side of the comparison too.
  • UnusedVariable, unconditionally, under findUnusedVariablesAndParams/--find-unused-code. A <x-...> tag compiles to a $__componentOriginal*/$__attributesOriginal* save-and-restore pair around the render, and @foreach/@forelse reassign $loop = $__env->getLastLoop(); at the end of the loop; none of that bookkeeping is ever read back, so it reports UnusedVariable on every template that uses either construct. UnusedForeachValue is not part of this drop: it fires on the author-named foreach variable, not compiler bookkeeping, and is real signal. Trade-off: an author’s own dead store inside @php and an unused foreach key also report as UnusedVariable and are silenced along with the compiler noise.
  • UnevaluatedCode, but only the message Psalm uses for code following return/throw/continue: a @switch arm’s @break leaves Psalm treating the following @case/@default line as unreachable, and that specific message is dropped. UnevaluatedCode is not one shape: the same class also reports 'gettype cannot return this value' for a gettype() comparison Psalm can prove impossible, which is a genuine author bug and is never gated by findUnusedVariablesAndParams, so it is matched on message rather than dropped as a whole class and keeps reporting. The message identifies the Psalm code path that emitted it, not who wrote the unreachable code: an author’s own unreachable statement after a return/throw/continue inside @php or raw PHP in the template gets the identical message and is silenced along with the compiler’s @break shape. The relocator sees only the issue’s message and location, not the AST, so it cannot tell the two apart; the loss is accepted rather than chased with snippet heuristics.
  • InvalidScope’s Use of $this in non-class context and NonStaticSelfCall’s Cannot use self/static outside class context (whichever casing the author wrote) (#1559). Unlike the rest of this section, this is not compiler-rewritten code: $this, self:: and static:: are exactly what the template author typed, but a Blade shadow is classless global scope (a plain page or component template alike), so Psalm’s own out-of-class checks trip on every mention, conventions some component libraries rely on inside their own template markup. Dropped unconditionally. The self/static match is case-insensitive but anchored to those two keywords only, never a bare wildcard: Psalm’s own keyword check is case-insensitive too, but its parent handling is case-SENSITIVE, so PARENT::x()/Parent::x() outside a class falls through to this identical message shape (Cannot use PARENT outside class context) rather than ParentNotFound. The parent:: family is out of scope (near-zero corpus prevalence) and must not be caught by a looser pattern.

    Not dropped, all three genuine author mistakes: Invalid reference to $this in a static context and Method X::y is not static, but is called using self::, reported only when a @php class ... @endphp block compiles a real class into the shadow; and Invalid reference to $this in a non-class context (VariableFetchAnalyzer.php’s sibling message for a bare $this fetch), which looks like the same classless-shadow shape but is not one: a non-static closure written inside a STATIC method of a compiled class has no $this in its own scope either, because ClosureAnalyzer.php only threads $this into a closure’s scope when neither the enclosing method nor the closure itself is static, so that closure’s own $this-> read trips this message even though it sits lexically inside a class. A residual gap is accepted: a named function or closure declared directly in a classless template, whose body itself calls $this->m(), still emits the FLOODING Use of $this in non-class context message and is still dropped, because the relocator has only the message and location to go on, never scope information.

  • UndefinedThisPropertyFetch on a property of the Livewire component the view’s seed binds $this to (#1935), matched on the issue’s property_id naming that class. Livewire’s __get() answers #[Computed] and getXProperty() reads and Filament’s $this->form, but Psalm 7 seals magic properties by default, so every such read would report where the untyped view used to hide it as Mixed*. Like the item above, this is the author’s own code, not the compiler’s. $this inside a class the template declares names that class and keeps reporting. Trade-off: a typo’d $this->property in a Livewire view stays silent, as it was before the view was typed.
  • RedundantCondition, RedundantConditionGivenDocblockType, DocblockTypeContradiction, TypeDoesNotContainNull and TypeDoesNotContainType, when the message names $attributes, $component, $errors or $slot. A <x-...> tag’s compiled bookkeeping (isset(), ??=, instanceof) checks exactly what those names’ ambient typing declares as guaranteed, most visibly when a <x-...> tag renders another <x-...> tag nested inside it. @props(...) REPLACES the prelude’s docblock type with an inferred one, so a nested tag inside a @props component view reports the inferred-branch pair (TypeDoesNotContainNull/TypeDoesNotContainType) rather than the docblock-branch trio, and both are dropped. Gated on the message rather than the class, so an author’s own redundant condition against their own docblock (@if (is_string($range)) under a <?php /** @var string $range */ ?> docblock, or @if (isset($range)) under an @php one) keeps reporting even though it shares a class with the dropped shape.

    $attributes and $slot also require a component view (see Ambient variables) for this message gate, so a local variable an author happens to name $attributes/$slot in a plain, non-component template is untouched; a plain caller’s compiled $attributes check is covered by the isset($attributes) && ... item below. $component drops with no such requirement (#1532): the prelude types it, if at all, only from the tag bookkeeping, nullable, so its narrowing comes entirely from a PRECEDING <x-...> tag’s own compiled resolve() call rather than from anything the prelude declares, and that shape fires just as much inside a plain page with a nested <x-...> tag as inside a component view.

    $errors also drops with no component-view requirement (#1546): ShareErrorsFromSession runs in the web middleware group, not in render() itself, so the prelude declares it in every shadow regardless of whether the view is a component. Unlike $component, $errors’s ambient type comes ONLY from the prelude’s own @var docblock, never from inference, so its match is narrowed to the docblock-branch wording alone (Docblock-defined type ... for $errors is ... / Cannot resolve types for $errors - docblock-defined type ...). An author who reassigns $errors before guarding it (@php $errors = 42; @endphp @if (is_string($errors))) replaces that docblock type with an inferred one, which renders the OTHER, un-prefixed wording (Type 42 for $errors is never string) and survives. A wider match that dropped it unconditionally would silence those.

    The trade-off this gate does NOT avoid: it cannot tell compiler-generated bookkeeping apart from an author’s OWN @if (isset($attributes))/instanceof/@php $component = .../@if (isset($errors)) check written against their OWN docblock inside their own template; that guard is silenced too, with no trace, because it names one of the same ambient variables and keeps the docblock provenance the drop for $errors requires.

    Dropping the guard’s own issue does not undo what it leaves behind: the impossible negative arm still merges a reconciled null into $attributes’s type at the guard’s endif, which surfaces downstream as a false PossiblyNullReference on the next $attributes-> read. $attributes is therefore re-asserted non-null right after both compiled endifs that can leave it in that state (the nested tag’s own inner strip and the enclosing tag’s restore), so the message gate above does not need to cover PossiblyNullReference too. See the known-limits entry below for the one window this does not close.

  • The guard family above on the isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag a <x-...> tag compiles twice (the resolve() data merge and the ignored-parameter strip), in any template (#1873). A plain caller’s prelude types $attributes from the tag bookkeeping (see Ambient variables), which makes the instanceof redundant after the isset(). Dropped only in a shadow whose prelude typed $attributes that way, when the issue selects exactly that expression, and when the expression does not occur in the template source as Blade rewrites it (comments and component-class markers removed), so an author’s own @if (isset($attributes) && ...) keeps reporting. Residual: a template whose inert text (an @verbatim body, plain markup) contains the expression keeps the finding on every compiled copy in that file.
  • The guard family above (RedundantCondition, TypeDoesNotContainType, and the rest) on the isset($value) that @session/@context compile to save an outer $value (if (isset($value)) { $__sessionPrevious[] = $value; }, #1724). With a $value of the author’s in scope (a foreach value, @php $value = null; @endphp), Psalm judged that compiler-written guard against it. Dropped only when the issue sits on that exact isset($value) and the save statement does not occur in the template source, so an author’s own @if (isset($value)) keeps reporting. Residual: a template that writes the save statement itself keeps the finding on every compiled save in that file.
  • InvalidArrayOffset, when the message names $__errorArgs as the accessed variable AND the offset is the literal 1 (#1566). A single-argument @error('field') compiles to $__errorArgs = ['field']; $__bag = $errors->getBag($__errorArgs[1] ?? 'default');, and Psalm proves offset 1 absent from that literal one-element list on every mention: bookkeeping the author never wrote and cannot act on. The match is exact-name on $__errorArgs, not a wide __-prefix family: @error is the only Blade bookkeeping that indexes a possibly-absent offset this way, and a wider match would also swallow an author’s own $__foo[9] typo. It is also pinned to offset 1 specifically, not any offset on $__errorArgs: a keyed argument (@error(1 => 'field')) compiles to $__errorArgs = [1 => 'field'], which has no key 0, and the compiled $__bag->has($__errorArgs[0]) read then reports this same class with the offset-0 wording, a genuine malformed-input bug (runtime Undefined array key 0) that keeps reporting. An author’s own out-of-bounds read (@php $arr = ['only']; echo $arr[1]; @endphp) names $arr in the message and keeps reporting too. Two residuals: an author who writes $__errorArgs[1] themselves inside @php is silenced along with the compiler shape (the same name-exact trade every gate in this section makes), and the spread form @error(...$args) compiles to a list Psalm cannot size, so it reports PossiblyUndefinedArrayOffset instead, a different class this gate leaves untouched.
  • PossiblyInvalidMethodCall and PossiblyUndefinedMethod on the synthetic iterator call of a @foreach/@forelse over a union subject (Collection<int, X>|list<X>, #1810). Psalm types such a loop through a getIterator()/current()/key() call on the compiled $__currentLoopData and suppresses both classes around it, which the remap cannot carry. Matched by position: the synthetic call sits on the $__currentLoopData variable, while a real $__currentLoopData->count() sits on the method name and still reports. A raw PHP foreach over the same union inside @php is not covered.
  • PossiblyInvalidMethodCall on the subject of an array access such as `` where $x or $x['k'] may be an ArrayAccess (#1909). Psalm analyzes a virtual offsetGet()/offsetExists()/offsetSet() call there and suppresses the issue around it, which the remap cannot carry. Matched by position: the virtual call sits on the subject, immediately followed by [, while a real $obj->items()['k'] sits on the method name (followed by () and still reports, as does a static call. Residual: whitespace or a line break between the subject and [ ($x ['k']) is not matched.
  • PossiblyNullPropertyFetch on $loop->parent and $loop->parent->parent (#1696). $loop->parent stays |null in the ambient loop type because an outermost loop has no parent, but the prelude cannot see lexical nesting, so every nested `` reported. Matched on the message naming the $loop->parent chain, so a nullable ->parent on any other variable still reports. Trade-off: three real null reads are silenced too: $loop->parent->x in an outermost loop, the same read outside any loop (where the prelude declares $loop ambiently), and a $loop the author declares in their own docblock with a nullable parent.
  • PossiblyInvalidArgument and PossiblyFalseArgument, only on argument 1 of an e() or echo call the compiler synthesized. compiles to `echo e($x)` and `{!! $x !!}` to a bare `echo $x`, so a value whose type is only partly echoable (`string|array|null`, or `string|false` from `parse_url()`) reports against a callee the author never wrote, and their only fix is a cast on every optional field. Told apart the same way as the arity gate: the enclosing call is cut out of the compiled line (backwards from the argument this time, since that is what the issue points at) and kept only if it occurs in the raw template. That is the whole discriminator, because `@php echo e(old('k')); @endphp` and compile to byte-identical lines.

    InvalidArgument is not included: a definite array in an echo position is a guaranteed htmlspecialchars() fatal, not a possibility, and keeps reporting. Neither is any other argument position: an author’s own e($x, $flag) or @php echo $a, $b; @endphp reports as argument 2 and is untouched.

    A missing call only says something about the callee when the argument itself survived compilation unchanged, so that is checked first and a rewritten argument keeps its issue. Blade does rewrite raw text inside an author’s own expression: @@foo is unescaped to @foo before echos are compiled, the ##BEGIN-COMPONENT-CLASS## markers are stripped from the finished output after @php blocks are restored into it, and a registered precompiler (Livewire) may rewrite anything at all. Without that check, reaching the analyzer as `echo e(e(old('@foo')))` would let the author's own inner `e()` be read as the compiler's and its issue dropped. The cost is noise on the mirror-image shape: when Blade rewrote the argument of a genuinely generated echo (), the callee can no longer be proven and the issue is kept.

    Two further shapes the gate does not cover, both leaving the finding visible rather than losing it: a custom Blade::setEchoFormat('myEsc(%s)') changes the callee name, and a registered Blade::stringable() handler wraps the value in $__bladeCompiler->applyEchoHandler(...) so the reported callee is that method. As with the arity gate, a template that contains an author-written e(/echo of the same expression anywhere in the file keeps its issues everywhere in that file.

  • Argument issues on the calls @aware’s compiled loop makes (#1695). @aware([...]) compiles to a foreach over its literal array whose body calls $__env->getConsumableComponentData($__key, $__value) for a string key and the list form $__env->getConsumableComponentData($__value) for an int key. Psalm does not correlate that is_string($__key) ternary with the key it narrowed, so the @aware line reports in two shapes. A keyed non-string default (null, false, 0, [], an enum) reaches the list-form arm and reports an argument 1 issue (NullArgument, InvalidArgument, PossiblyNullArgument), or ImplicitToStringCast for a Stringable default. Psalm 7.0.0-rc28 no longer adds an InvalidCast next to the argument issue for an array, object, or enum default (vimeo/psalm#13036). An all-int-key list (@aware(['color'])) narrows $__key to never in the keyed arm and reports NoValue. Both are dropped only when the issue points exactly at that $__value/$__key argument and the generated call text up to it ($__env->getConsumableComponentData($__value, or $__env->getConsumableComponentData($__key for the keyed arm) does not occur in the raw template, so an author’s own call with the same arguments inside @php keeps reporting.

    The discriminator is text, and it is per template, not per line. Writing that call text anywhere in a template (@php, raw PHP, or even plain markup; a `` comment does not count) switches the matching arm’s gate off for the whole file, and from then on every generated @aware line in that template reports the artifact again, next to the author’s own call. Each arm is switched off only by its own text: $__key call text leaves the list-form gate on, and $__value call text leaves the keyed gate on.

The arity gate applies to TooManyArguments alone. A compiled @include or @extends chain also expands into calls ($__env->make()), and gating the whole family on the same rule would silently hide a real MissingView. The echo-position gate above reuses the same “does this piece of the shadow occur in the template?” mechanism but is anchored on two specific callees at one argument position, so it cannot reach any other call. Anything either gate cannot read, or cannot recognise as the call shape it expects, keeps its issue: the failure direction is extra noise rather than a lost finding.

A match follows Psalm’s behavior on plain PHP (#1801). Inside an arm (its conditions and body) RedundantCondition and RedundantConditionGivenDocblockType are dropped, as Psalm drops them there; the match subject and a match used as an operand (if (match (...) {...})) still report them. Accepted residual: for a match without a default arm Psalm analyzes the arm conditions a second time outside that suppression and can report there, a template does not.

Annotation implementation

Usage, output, flags and what the command writes: Annotating templates. psalm-laravel blade:annotate is the one path on which this plugin writes to the analysed source tree. It is a two-half pass inside an ordinary analysis, registered from Plugin::registerHandlers().

  • Gate. Blade\Annotate\AnnotateRequest::fromEnvironment() accepts a control file the CLI put in the child process’s environment, carrying the psalm-laravel-annotate marker key, on a regular on-disk file (a data:// URL would otherwise satisfy the marker with no file at all). The marker is what makes the variable safe to leak: a stale shell or a .envrc (PSALM_LARAVEL_BLADE_ANNOTATE) pointing it at any other readable JSON (a composer.json, say) would otherwise arm the codemod AND have publish() overwrite that file. It is re-checked inside publish() AND in AnnotationWriter::run() before the first template is written, because the path is named by an environment variable and can be repointed mid-run. No config flag turns the write on, so a plain vendor/bin/psalm run cannot reach it even with Blade fully configured; conversely a valid request force-enables Blade for a project that has it off, and fails with the reason if Blade disables itself. Annotate mode also forces the collectDataIncludes half of the compile pass on, because the read set is what it annotates from, and skips taint analysis, which the command never reports.
  • Process ownership. Cli\AnnotateCommand runs vendor/bin/psalm through proc_open (the AnalyzeCommand shape), forwards every token except its own --dry-run, and refuses a subset of the project (-f, or a bare path): agreement between call sites is a claim about all of them. The plugin pins the analysis to one process (ProjectAnalyzer::getInstance()->threads = 1), while --threads still sizes the scan. Single-process analysis is not a preference: statics collected in a forked analysis worker never reach the parent, and the parent is where AfterAnalysis runs. Measured on a scratch project, --threads=4 reaches the writer with zero collected call sites. The plugin half does not trust the command for this: AnnotationCollector::sawAnalysis() is false in the parent of a forked run, and AnnotationWriter::run() then publishes an error instead of writing mixed over the whole view tree.
  • Collection. Blade\Annotate\AnnotationCollector (AfterStatementAnalysis) records producer types through the same ViewCallChain::from() walk ViewContractHandler checks call sites with. typeFor() answers a type only when every producer of that view agreed (identical types, or a supported covering merge from merged(): nullability, one scalar kind, up to three classes, arrays one call site’s type already covers; never a common parent) AND no producer left the data set open (an open set can carry the same key with another type invisibly), and only when the id survives a Type::parseString() round trip, carries no template parameter at any depth (a TypeVisitor walk, because Union::hasTemplate() is false for Collection<int, T>), and cannot close the docblock it is written into (*/). A mixed producer, an open producer and a type that cannot be safely serialized decline. Literal precision is dropped and int<min, max> is written int.
  • Unreadable producers. A rendering shape the call-chain walk declines (not only $view = view(...)) marks every literal view name inside the declined expression unreadable for the whole run. A call inside a closure or arrow function is read on its own as the statement in that function’s body, so the declined outer chain does not mark it again; a call the walk declines there stays unreadable, as does every call in a declined expression that could write data into the view a callback returns (with...() including magic ones, nest(), an offset or property assignment, a method with a non-literal name).
  • Other unreadable and owned sources. AnnotationCollector::conditionsOf() hands the conditions of an if/elseif, a loop header and a switch (with its case values) to the same nested-render scan, since a call there is no statement of its own. isViewBinder() also matches the Factory methods first/renderWhen/renderUnless/renderEach/startComponent* on the View and Factory facades (by the resolvedName Psalm records, not the spelling) and on a receiver that is view() with no arguments, besides $__env. typeFor() and disagreeingTypes() answer null for a key ContractRegistry::composedKeys() lists, or for every key when that is null (an unreadable composer), reusing the registry the call-site checks consult.
  • Deferred edges. An @includeIsolated key passed as a bare variable Psalm saw as mixed (the including template is not declared yet) is stored as a deferred edge in AnnotationCollector::record(), not observed. observations() resolves it at AfterAnalysis from the including template’s per-file plan (AnnotationWriter::fileVariable(), |null for an = null declaration) and memoizes per pair; an unresolved edge or a cycle observes mixed. Eligible only when the including template just forwards the variable: AnnotationCollector::scan() (one memoized pass per file) declines a name that occurs anywhere in the file other than as a by-value item value in the data array of a top-level make/renderWhen/renderUnless/first call, and every name of a file that may write variables by name (extract, call_user_func, call_user_func_array, a non-name callee, a use function import, include/require/eval, several namespaces). Psalm leaves no trace of what a condition, a loop, a receiver use, a by-reference or asserting argument, or a magic accessor does to a variable while it is still mixed, so no syntactic allowlist of harmless uses is sound. Also declined: an include whose data has a spread or computed key.
  • Writing. Blade\Annotate\AnnotationWriter (AfterAnalysis) joins ViewReferenceRegistry::templates() (paths, published unconditionally) with ContractRegistry::contractFor() (declarations and read set), plans per template FILE rather than per view name (a published override is claimed under two names and would otherwise be reported twice), and splices with Blade\Annotate\TemplateAnnotator. One splice per template: an insertion, or the header docblock rewritten with its text moved off the /** line so every tag sits on its own aligned line. Idempotency comes from re-reading the declarations out of the source, not from a marker, and TemplateAnnotator reads them with ContractParser::rawDeclaredNames(), the same reader the contract itself uses, so neither side can bind a name the other misses. A pattern of its own that bound a different name would append a duplicate declaration on every run: \w binds $men out of $menü. Existing declarations are never narrowed or rewritten, and a template whose compiled body hides which names it reads (@props, @aware) is skipped whole.
  • Local variables. ViewDataContract::$localVariables exists for this pass alone: every name the compiled body binds for itself (a @foreach alias, an assignment target, a closure or arrow-function parameter, a catch variable, a static/global declaration). The read set deliberately KEEPS them, but declaring one reports MissingViewData at every correct call site, so the writer subtracts them, along with $rawDeclaredVariables (already declared). A use ($x) clause is not in the set: it reads the enclosing $x. Both ride the contract slot of the shadow manifest; an entry written with a narrower shape is dropped by normalizeContract() and recompiled. A variable some closed call site renders the template without is written as T|null $x = null, with no second |null when the type already has one.
  • Array shapes. Types are written as inferred, so a single call site passing ['a', 'b'] declares list{string, string} and a later call site passing three elements reports a type error on correct code. Annotating changes what the other checks see: the written declarations are what the view data checks check call sites against, what UnusedViewData counts as wanted, and the types the template body is analyzed with.

Known limits

User-facing limitations are listed in Blade Template Analysis (layout contracts, standalone analysis, silent mixed, HTML-context blindness, dynamic view names). Two of them have implementation detail worth keeping here:

  • Includes, extends and component recursion are resolved only for the reference and read-set graphs, never to type a template’s body.
  • A component tag (<x-...>) needs the full application container to resolve, which the standalone compile pass does not always provide. A template that fails this way is skipped and reported as BladeCompileError; other templates are unaffected.

The following are implementation-specific.

  • A nested parameter hides a same-named contract. A template that declares $title and also has fn (string $title) => … (or a closure parameter, catch, static or global of that name) anywhere treats $title as its own local and declares no contract for it: the template-wide set of bound names does not tell scopes apart, so the check misses a missing key (never reports a false one).
  • A layout’s declarations are not checked at its children’s call sites. A variable declared in a layout reached through @extends is required by the layout, but only the rendered template’s own contract is checked against a view() call site.
  • View data named component is shadowed after a <x-...> tag. Blade uses $component for the tag’s component (documented for scoped slots; inside a slot it is typed as the tag’s class). Psalm cannot follow Blade’s save and restore around the tag, so your own $component is typed YourClass|AnonymousComponent after it. Pass the value under another name.
  • A dynamic read of $component or $attributes sees the bookkeeping type. compact('component'), $$name, or get_defined_vars() in a plain caller reads the name without a $component/$attributes token, so the prelude still declares it Component|null/ComponentAttributeBag|null (#1873). A direct $component read in the same template keeps it mixed.
  • A non-Blade file in an earlier view root is not seen. With two config('view.paths') roots, a widget.php in the first one is what Laravel renders for view('widget'), while the check uses the second root’s widget.blade.php. Namespaced names are resolved through the finder and are not affected.
  • A multi-line construct can report on the line that opened it. Each template line carries a marker comment into the compiled shadow, and the reported line is the nearest marker at or above the issue. Statements inside @php and raw <?php blocks carry their own marker and report on their own line. Three shapes cannot: a construct whose interior is quoted text (a heredoc, a nowdoc, a multi-line string) or a block comment, since a marker placed there would become part of that text rather than a marker; a ``/{!! !!} echo, a @directive(...) argument list, or a @switch/first @case span written across lines, since Blade rewrites those and a marker inside one stops it matching; and a multi-line component tag, covered below. Each reports on the line its construct opened on, so the drift is bounded by the construct’s own height.
  • A multi-line <x-...> tag reports on its opening line. ComponentTagCompiler emits a fixed five-line shape for any tag and joins every attribute onto one of them, so no attribute’s own source line survives compilation and there is nothing to map back to. A marker cannot be injected between two attributes either: the compiler matches an attribute list as a strict alternation, and one extra comment leaves the whole tag uncompiled as literal text. The same holds for a precompiler that owns a tag prefix (<livewire:...>, <flux:...>, <native:...>), so any multi-line tag whose lowercase name carries a prefix: reports on its opening line too. Residual: a multi-line XML-namespaced HTML tag (<v:roundrect ...> in an email template) is treated the same way, and a finding inside its attributes reports on its opening line.
  • A malformed component tag reports a genuine ParseError, located at the tag. An empty bound attribute (:value="") compiles to a dangling , or ] in the component’s data array, and a closing tag with no matching opener (a stray </x-alert> over a self-closed <x-alert />) compiles to an orphan @endif. Both are real bugs Laravel’s own compiler would hit at render time, not remap artifacts. The dangling array element reports on the tag’s opening line, and the orphan closer reports on the stray closing tag’s own line; neither shape has a per-attribute line to report on, for the same reason a multi-line tag does not: no marker survives inside a component tag’s compiled output. An orphan closer’s error can also surface at a later directive instead, when its stray @endif epilogue consumes an enclosing conditional (the parser only trips where the imbalance becomes unparseable).
  • A ParseError in a template’s compiled shadow is reported on a cold cache only. A warm run reports nothing for it until the cache is rebuilt, exactly as plain Psalm does for any PHP file with a syntax error (vimeo/psalm#12319). Run with --no-cache (or clear Psalm’s cache) to see it again.
  • A non-string @aware list item is not reported. @aware([null]), @aware([$n]) with a nullable $n, and a bare null next to a keyed pair are silenced along with the compiled @aware loop’s own argument issues (see Compiler-generated code), because the two cannot be told apart. Each is a bug at runtime (a null key).
  • A component view that writes a <x-...> tag BEFORE its own @props/@aware/$attributes marker, and reads $attributes-> between the two, can get a non-null assertion it has not earned (#1543). A nested tag’s compiled bookkeeping re-asserts $attributes as non-null right after its own save/restore endifs (see Ambient variables), unconditionally: it cannot tell whether a read that follows comes before or after the marker that makes $attributes genuinely non-null (@props’s own ??= guard, in particular). Verified narrow: a read placed after the marker, the normal case, is unaffected either way, and a read after the tag or inside its own body is correctly typed regardless of which of @props, @aware, or a bare mention declared the view a component. The one case this re-assert skips outright is a template that itself assigns to (or unset()s) $attributes anywhere in its own source: the assertion cannot prove Laravel’s isset($attributes)-gated save/restore ran again after that write, so the re-assert is withheld for the whole template and the original nullable/mixed typing (and its false positives) applies instead.
  • Class component view typing is a lower bound on detection and an assumption about the render path. Only a compiled <x-…> tag or @component(Foo::class) proves a class is rendered as a component; <x-dynamic-component>, Blade::renderComponent(), and tags inside package views under vendor/ are not seen, so their views stay mixed. Once one tag is seen, the view is typed as the tag renders it: a controller (or a manual $component->render()) rendering the SAME view gets none of the data() keys at runtime, and the template is still typed as if it did. A template that renders it by name (@include and friends) is seen and declines; PHP call sites are not, because they are only known during analysis. A named slot whose name is an expression (<x-slot :name="$x">) is not seen either.
  • A false sentinel @props default types the body bool. 'icon' => false with string callers makes `` under @if ($icon) report InvalidScalarArgument. Declare the real type with a raw <?php /** @var string|false $icon */ ?>.
  • A [] @props default types the body array. With Collection callers, an @if ($items instanceof Collection) guard reports DocblockTypeContradiction. Declare the real type the same way.
  • Suppressions for dropped findings can report UnusedPsalmSuppress. A `` comment aimed at a finding the remapper already dropped (any Mixed* issue, or any compiler-generated finding listed in Compiler-generated code) is carried into the compiled shadow, but the issue it would have silenced is dropped before the comment is checked against it. Under --find-unused-psalm-suppress it reports UnusedPsalmSuppress on the template line. Remove those comments; these drops have no opt-out.
  • Some vendor directives still report on their compiled code. Inertia’s @inertia/@inertiaHead (PossiblyUndefinedGlobalVariable and RiskyTruthyFalsyComparison on $__inertiaSsrResponse, an always-falsy config() operand) and Filament’s @endcapture (PossiblyFalseArgument on ob_get_clean()). Each is a handful of findings per app; suppress them per template or in <issueHandlers>.

This site uses Just the Docs, a documentation theme for Jekyll.