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.phpfile under the booted application’s view paths (config('view.paths')), plus every namespace hint a service provider registered withloadViewsFrom()(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,@extendsand component tags are not followed into the files they reference. - Issues found in the shadow are relocated onto the
.blade.phppath 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 ownVoidProgressdrops 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 ofProjectAnalyzer’s project-file list:TaintFlowGraphdrops a flow whose source sits in a reportable file that suppressesTaintedInput. Psalm’s ownaddFilesToShowResults()is redundant here becauseAnalyzer::addFilesToAnalyze()already writes the same map. - The template is written into
ProjectAnalyzer::$project_filesby reflection (Blade\ProjectFileInjector), because that private list is built from psalm.xml before plugins initialize and is allcanReportIssues()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 viaCodebase::queueClassLikeForScanning(). Every shadow declares them as stacked one-line@vardocblocks, and PhpParser attaches every stacked comment to one node’s comment list, soNode::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 reportUndefinedDocblockClassthe first time nothing else in the project names them in code position (#1494). - String-literal class candidates (
Blade\ClassLiteralCollector) are queued viaShadowRegistrar::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.BladeBootstrapperreads every shadow off disk (not the freshShadowResult, which does not exist on a warm-manifest run) after both other registrations, tokenizes it (T_CONSTANT_ENCAPSED_STRINGonly, so docblocks and interpolated strings are excluded), and hands the candidates to the Psalm-side adapter. The adapter gates each one onConfig::getComposerFilePathForClassLike()or an already-declaredclass_exists($n, false)-family check before queueing it withstore_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 listApplicationProvider::runtimeDeclaredFunctionFiles()captured around the boot. A package-style monorepoincludes its helper file from a provider’sregister(), so nothing in the project references it and Psalm would give it noFileStorageat 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 precederegisterShadowsForAnalysis(), the one irreversible step.ShadowRegistryis filled in afinallyaround it, because remap entries only RELOCATE issues whileContractRegistryvalidates call sites andViewReferenceRegistrydrives 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.
--debuglists every individual skip cause. Compiler and parser failures already reportBladeCompileErroron their own.--threads=1when stepping throughBladeIssueRemapHandlerorJourneyRemapperwithvar_dump(): forked worker processes swallow output. Clear the resolvedcacheDirto force the compile path you are trying to observe;--no-cachealone 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’srender()outside the shape above names it too, no compiled<x-…>or@component(Foo::class)renders the class, ordata()is not knowable: the class overridesdata(), an extraction method (extractPublicProperties(),extractPublicMethods(),createVariableFromMethod(),ignoredMethods(),shouldIgnore()), or redeclares$except(an$exceptassigned at runtime, say in the constructor, is not seen: its names are still typed). It also staysmixedwhen any template renders the view by name (@include,@includeIf/When/Unless/First,@each,@extends,@component('name')), which hands it none of thedata()keys. The data arguments must be literal arrays passed positionally:compact(…), a variable, named arguments, or a->with()after the call decline too. An unqualifiedview()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@vardocblock, 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
ClassLiteralCollectorfeedsBlade\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 runsdata(). Both are buffered and published with the other template facts, so a degraded boot leavesBlade\ComponentViewRegistryempty. AfterCodebasePopulated.ComponentViewRegistry::build()walks class storage for non-abstractComponentsubclasses, resolves the declaringrender()throughdeclaring_method_idsandInternal\Ast\ClassMethodResolver, and maps a literal view name to a template throughViewReferenceRegistry. A template claimed by more than one concrete class, or named by arender()outside the recognised shape, declines. Keys come fromHandlers\Views\ComponentRenderData::guaranteedFor(), which shares its exposure walk with the producer-sideforSource()but declines whereforSource()may over-approximate ($except,ignoredMethods(),shouldIgnore()). Static, so reset inPlugin::resetInvocationState(); built pre-fork, so workers inherit it.AfterStatementAnalysis. The seed lands on the prelude’s sentinel, theStmt\Nopits closing tag parses to: the only statement on a lineShadowEntry::$lineMapsends to 0. Seeding on any laterNopwould undo a template’s own reassignment; thebadgefixture pins that. Psalm applies a statement’s@vardocblocks before analyzing it, so the prelude’s@var mixednet 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_typepins the CLI behaviour. - Types only. A seeded
Unionhas noparent_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’sattributes|slotguard drop also covers a nested tag’s compiledisset($attributes)/instanceofbookkeeping in a view that never names$attributes.AttributesRestoreReassertkeeps its source-only gate: it runs at compile time, before the registry exists, and such a view has no$attributesread 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 arender()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 onlyreturn. Statements may precede it (a query into a local, say); then any otherreturnin 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 afterrender()’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.
ViewTaintBridgeattaches class-component property taint only, so$postcarries none into the view. A$this->postread 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, andFunctions::getStorage()throwsUnexpectedValueExceptionrather 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 ownFileStorage::$functionsholds it, and a constant only when$constantsdoes.- 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
FileStoragewould suppress genuineUndefinedFunctionacross the codebase. - Anything
Functions::hasStubbedFunction()already answers is skipped, so a user helper never outranks a stub’s types, and the capture excludesvendor/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 beforeanalyzeFiles()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 afterdoGetApp(), andApplicationProvider::reset()clears it. A second boot in the same process (a language server, a test harness) finds the helper files already loaded (require_oncedeclares nothing twice), so it captures nothing and the shadows report the helpers as undefined. Carrying the capture acrossreset()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 onlydefine()s constants is never found either: the files come fromReflectionFunction::getFileName(), andReflectionConstant::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\Stringableby design. Laravel’se()has no type declaration on its$valueparameter;htmlspecialchars()coerces it to string at runtime regardless of caller strictness, so passing a\Stringableis safe everywhere, not only at echo positions. Aboolis accepted for the same reason (renders `'1'` or `''`).and an explicite($stringable)call anywhere, including inside@php, do not reportImplicitToStringCast. Other sites are unchanged: a\Stringablepassed to a plain function such asstrlen(), or used in a concatenation understrict_binary_operands, still reports.e()accepts the\Illuminate\Contracts\View\Viewcontract (#1776).view(),Component::render()and->with()are typed as the contract, which extends onlyRenderable, so `` (compiled toe($view)) would reportInvalidArgument. The only framework implementation,\Illuminate\View\View, isHtmlable, soe()takes itstoHtml()branch at runtime. Taint handling ofe()is unchanged.old()returnsmixedfor a key. Flashed input holds whatever reached the session: JSON scalars from$request->input(), any value passed towithInput(array), and aModeldefault’s attribute. Laravel’s ownstring|array|nullis narrower than that, and$defaultaccepts any value. Since theMixed*family is not reported in templates,andreport 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|nullandmixed: a nullable type means “passnullexplicitly”. = nullright 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 asT|null: Psalm reads the docblock as written, so@var string $x = nulltypes the bodystring; it is reported asInvalidDocblockat the declaration (Blade\RawDeclarationHandler, fromBladeBootstrapper::registerContract()’s problems), because body and call site would disagree. Text after the= nullis 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@extendsFirstdo 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.@eachand@componentare 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@vardocblocks and@props([...])declarations of a template. The declarations are never fed intoShadowCompiler::compile(): a raw docblock already sits in the shadow body, where Psalm reads it itself. A line is split with Psalm’s ownCommentAnalyzer::splitDocLine()(type = first balanced token, name = the$identifierafter it, the rest a description), by one reader shared withContractParser::rawDeclaredNames().parseDeclarations()walks the compiled output only when the source contains a@var. - Registry.
Blade\ContractRegistrymaps view NAME to contract.BladeBootstrapper::compileAll()derives it from the template path relative to its view root (first root winning asFileViewFinderdoes) but only BUFFERS it; the registry is filled bypublishTemplateFacts()after activation, in insertion order, so the first-root precedence still lives inregister(). It is static because Psalm instantiates event handlers itself, as areShadowRegistryand the other registries, so all are reset inPlugin::resetInvocationState().publishTemplateFacts()strips framework-shared names (appplusFactory::getShared()keys) from every contract, and publishes the composer sources read at boot right after, throughContractRegistry::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’sIlluminate\Events\DispatcherWITHOUT running them: the raw listeners ofcomposing: {view}andcreating: {view}, plus thewildcardsproperty matched withStr::is(). A listener counts only when it is the closureFactory::buildClassEventCallback()builds (declaring file and line range, capturedclassandmethod), a wildcard one unwrapped from itsaddEventListener()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 sourcesnull(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 samequeueClassLikesForScanning()call as the ambient classes, before the irreversibleregisterShadowsForAnalysis().ViewContractHandler::afterCodebasePopulated()then runsBlade\ComposerKeyResolver, which resolves the method throughdeclaring_method_idsandClassMethodResolver(inherited and trait methods included) and walks its body: every occurrence of the first parameter must be the receiver of a literal-keywith()chain whose result is discarded, or ofname()/getName()/getPath(). Any other occurrence, ashare(), a call toextract()/get_defined_vars()/func_get_args()/func_get_arg()or acompact()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 keysnull. Results live inContractRegistry::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\ViewCallChainwalks 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 innerview('greeting')of aview('greeting')->with('name', $n)chain would report every declared variable as missing. It refuses on any link it does not model, andMissingViewDataadditionally needs the supplied key set to be provably closed.with()is read the way Laravel dispatches it, onis_array($key)rather than on the argument count, and the whole chain’swith()calls are deferred until the terminal receiver’s role is known, becauseSimpleMessage::with($line)on aMailMessagechain 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 aprivate/protectedmethod: the caller can still finish the view with->with(). It is theComponent::render()open set applied by visibility; public methods and closures stay closed. A trait method is analyzed once, as the trait, soisInNonPublicHelper()reads the visibility its using classes give it (trait_visibility_map, a public alias under a new name intrait_alias_map, a trait using the trait followed to its users); a storage lookup that throws keeps the set closed. OnlyMissingViewDataandAnnotationCollectorread the open flag, soblade:annotatetreats such a view as an open producer and declares no types from it. - Component data.
Handlers\Views\ComponentRenderDataadds whatIlluminate\View\Component::data()merges in when the rendering expression sits inside aComponentsubclass: public non-static properties with their declared types, public methods (minusignoredMethods()) asmixed, and the inherited$attributes. They land inViewCallChain::$frameworkData, not$data:supplied()unions both for the declared-variable checks,$frameworkDatawinning a shared key becauserenderComponent()merges it last, whileUnusedViewDatakeeps iterating$dataalone, or every component render would report one finding per public method.$exceptand an overriddenignoredMethods()only REMOVE names, so ignoring them leaves the set a superset, the safe direction; an overriddendata()can ADD them, so it opens the set instead. - Emission.
PreludeBuilder::BLADE_OWNED_NAMESis 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), notAMBIENT_TYPES(the three names the prelude ALWAYS types): a call site passing'slot'is never wrong about the template, even for a template whereattributes/slot/componentare typed conditionally or not at all (#1525, #1873). The check is deliberately not gated onViewCallChain::$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 thebuildViewDataUsing()callback, then every initialized public property whose declaring class is notMailableitself (statics included, asReflectionClass::getProperties(IS_PUBLIC)returns them), so a property overrides awith()key. The class read is the RECEIVER’s type ($order->view(...)from outside included);$thisis the enclosing class.Mailer::send()adds$messageandmailer;additionalMessageData()adds__laravel_mailable. A MailMessage chain also getsmailerand the__laravel_notification*keys (MailChannel::additionalMessageData()). Those keys aremixed;$messageis typed per path (below). An abstract Mailable leaves the set open, as a concrete subclass can add public properties. - MailMessage:
MailMessage::data()isarray_merge(toArray(), viewData), so the eightSimpleMessagekeys,displayableActionUrland$messagecount, and a key the call passes wins (the opposite ofsupplied(), so the call’s keys are left out of the framework set). A passed set that is not fully known drops thetoArray()keys. The eight property keys aremixed: the vendor docblocks (array,string) are coarser than the values and wrong for the null defaults, so typing them would reject valid declarations such aslist<string> $introLines. Only the computeddisplayableActionUrlis astring. - Livewire: ancestry only (
livewire\componentinparent_classes), inside a method namedrender(), and only for the chain that is the returned view: theview()helper or a view factory (View::make()), never a Mailable or MailMessage view built on the side inrender()(ViewCallChain::from()checks the chain’s binder). Rule, from livewire/livewiresrc/Drawer/BaseUtils.php(getPublicPropertiesDefinedOnSubclass()) andHandleComponents::getView(): public, non-static properties not declared byLivewire\Component/Livewire\Volt\Component, applied with->with()afterrender()returned. Stays complete: Livewire callsrender()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 dynamiccompact()) 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 (@propsand@awarecompile to$$namewrites) 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@includeor@extendsreads or declares counts as consumed. - A whole-scope call outside the recognised
$__envinclude methods (#2209: BookStack’sBlade::directive('include', ...)compiles to$__themeViews->handleViewInclude(...)) may render any view, so it declines the chain like a dynamic@include($name). Only the exactarray_diff_key(get_defined_vars(), ...)shape counts, so Livewire’swire:keyand Filament’s@capture(a bareget_defined_vars()) keep their coverage. The issue page lists the remaining gates.
Implementation:
- Read set.
ContractParser::parseDataContract()isparseDeclarations()plus a walk of the COMPILED output. It is a side channel: the contract is built afterShadowCompiler::compile()returns and never reaches it. OnewalkReads()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 $namedocblock (any<?phpor@phpblock, found throughMarkerPrePass::maskedRanges()) becomes aContractVarviarawContractVars(), minus the nameswalkReads()reports as locally bound (a type hint on a@foreachalias or an assignment target is not view data) andPreludeBuilder::BLADE_OWNED_NAMES. Only aT_DOC_COMMENTcounts, andoptionalis set only by a description starting= null(splitVar()): nullable andmixedtypes are required. A@propsentry wins a name collision with a raw docblock.ContractParser::withNull()widens an optional type toT|nullfor theInvalidViewDatacomparison and the annotator’sT|null $x = nulloutput. What does not become aContractVar(a local) still rides inViewDataContract::$rawDeclaredVariables, consumed-only:ReadSetResolverand the writer count it as declared. readsUnknownis load-bearing. A parse failure, a$$name, anextract(), or acompact()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 toarray_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.@includeIsolatedand@eachcompile 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()orrenderUnless()sets the dynamic flag. - Closure.
Blade\ReadSetResolvercloses 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 thatContractRegistrynever 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.
afterExpressionAnalysischecks the call name (view/make/with, plusfirst/renderwhen/renderunless/resolveonly inside a shadow, because they are common method names), thentracksTaintFlow(), thenViewCallChain::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 asComponentViewSeedHandlerdoes. For each variable in scope except$__env,$__pathand$__data(ViewTaintBridge::NEVER_VIEW_DATA), a read node$q (view data of {view})is attached to the variable’s type, reading both the exact nameqand the whole-array name*(patharrayvalue-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.BladeViewDataTaintTestpins it with a class component’s@propsview.
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$mergeDatafor 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', [...]))andview('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(), awith()key that may be an array).view('x', $data),->with($array),$mergeDataheld in a variable andcompact('q', 't')write the array under the store name*(a variable name is never*); the sink reads*with the patharrayvalue-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()andfirst()resolve throughViewCallChain::includedViews()(receiver resolved to the view factory, so a look-alikemake()on another class never counts) and write the passed keys and non-literal data like a PHP call.renderWhen(false, ...)andrenderUnless(true, ...)render nothing, so a condition Psalm types as always falsy (renderWhen) or always truthy (renderUnless) writes neither data nor scope. Forfirst()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 keepfirstExistingCandidate()). - 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$dataor$mergeDataposition (exactly those two keys: an author’s own filter such as['q' => 1]is not it), every plain$nameinContext::$vars_in_scopethat carries a taint node is written under its own name ($a['b']and$a->bcontext ids are skipped).@includeIsolatedhas 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 inpwhen the includer’s own$qis tainted, although the explicit data wins at run time. - Anonymous components write the
dataofAnonymousComponent::resolve(). The compiled call isresolve(['view' => 'components.x', 'data' => [...]] + $outerAttributes); the bridge reads the literalviewanddataentries of the left operand, in a shadow only. The tag also callswithAttributes([...sanitizeComponentAttribute($value)]), which routes the value throughe(): 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$__pathand$__data, andView::gatherData()merges a view’s own data after the shared values, so['errors' => $input]or['__q' => $input]reaches the template. The sink skips$__path,$__dataand$__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. $mergeDatacounts, 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$datawins$mergeData, asFactory::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 (ause functionimport 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 whosewith()ignores the data. - A padded literal name binds.
view(' search ', ...)renderssearch, becauseFileViewFinder::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, awith($key, $value)whose key is not a literal, and awith()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@awarepulls values from an ancestor component’s data; neither is a literaldataentry of the compiledresolve()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$mergewhole, so a tainted$merge['q']reports inpalthough$datawins 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
$nametoken.compact('q'),$$name,extract(),get_defined_vars()and a bare@php /** @var string $q */ @endphpintroduce$qwithout that token, so such a template reports noTaintedHtmlfor it. The same limit cuts a relay: an includer that never mentions$qhas no$qin 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, ...)toFoo::class::resolve(...)(closed with@endComponentClass;@endcomponentleaves anifopen that fails to parse).resolveis a template-only call name, so a hand-writtenFoo::resolve()in PHP code is not followed, nor is$container->make(Foo::class, [...]). The class must be aComponentsubclass other thanAnonymousComponent, which keeps its own branch. Only the literal left operand of the+is read. - Keys bind constructor parameters, not properties.
resolve()doesnew static(...array_intersect_key($data, array_flip($parameters))), so each entry is connected toDataFlowNode::getForMethodArgument()of the declaring constructor’s parameter (Psalm’s own node fornew 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 = $labelbehave as fornew. 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 whoserender()returns the view’s literal name (contested or not, whatever datarender()passes), built atAfterCodebasePopulatedfromComponentRenderData::exposedPropertyIds(). A read node$name (component property)gets a path from each and joins the variable’s view-data reads.SLOT_OWNEDnames are left out:componentData()overwrites$slotwith 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
$exceptdefault drops a name (read from the property’ssuggested_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 byecho (new B('safe'))->messagereports it without this plugin), so the fix layer is upstream. A public method sharing a property’s name (it wins indata()), a non-literal$except, and an overriddendata()orresolve()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\ShadowRegistrymaps shadow path to template path, line map and per-template-line suppressions.BladeBootstrapperfills 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.phppath (Blade\ShadowIssueRelocator), re-emits it throughIssueBuffer::accepts(), and returnsfalseto kill the shadow-path original.BeforeAddIssueis the only hook that can do this: Psalm dispatches it before both the reportability gate andIssueBuffer::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 withnull(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.phppath, so a<directory name="resources/views" />entry applies to a template line exactly as to a PHP file, with nothing extra, becauseaccepts()consultsConfig::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 asmixedand 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.UndefinedThisPropertyAssignmentis the deliberate exception: plain PHP reports nothing for it on a receiver such asSealedMagic::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 sameShadowTarget::templateLineFor()the location uses.first seen on line N(PossiblyUndefinedGlobalVariable,PossiblyUndefinedVariable) and php-parser’s ` on line Nsuffix onParseErrorare 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. Thefile:line:columndescriptor of the issue's own shadow (ReferenceReusedFromConfusingScope) goes throughJourneyRemapper::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.xpipeline does decline there, because Psalm 6 runs taint exclusively andIssueBuffer::add()discards non-Tainted*issues anyway.) - A
TaintedInputcarries two further constructor arguments describing how the taint travelled, andBlade\JourneyRemapperremaps 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 substitutingfile:line:columndescriptors 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 withnull, 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
MixedIssuefamily (MixedArgument,MixedAssignment, and the rest), which is never reported in templates because an undeclared template variable typing asmixedproduces it constantly. SeeMixed*issues. - Taint analysis:
TaintedHtmlon 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_TAGwithJSON_HEX_AMPclearsTaintedHtml, andJSON_HEX_APOS,JSON_HEX_QUOTandJSON_HEX_AMPclearTaintedTextWithQuotes. Your own flags keep each finding whose flags are missing:@json($value, JSON_HEX_TAG | JSON_HEX_AMP)still reportsTaintedTextWithQuotes, and flags withoutJSON_HEX_TAGorJSON_HEX_AMPreportTaintedHtml. 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 7ofPossibly undefined variable $x, the trailingon line 7of aParseError, and thefile:line:columnof a reference’s definition in aReferenceReusedFromConfusingScopeall count the template, not the compiled shadow. Afirst seenreference into compiler-generated code has no template line, so that clause is omitted. - Issue-level policy.
RiskyTruthyFalsyComparisonkeeps 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 of0rendering as “not rated”.PossiblyFalseArgumenton ajson_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 toecho 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:
MissingClosureParamTypeandMissingClosureReturnType, 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@phpor 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.MissingPureAnnotationon a closure, unconditionally, with the same trade-off (#1908). Livewire’s@livewireand<livewire:...>compile to a$__splitclosure that draws it on every tag.InvalidMethodCallon 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 ownxkeeps 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,{!! !!},@phpand 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, underfindUnusedVariablesAndParams/--find-unused-code. A<x-...>tag compiles to a$__componentOriginal*/$__attributesOriginal*save-and-restore pair around the render, and@foreach/@forelsereassign$loop = $__env->getLastLoop();at the end of the loop; none of that bookkeeping is ever read back, so it reportsUnusedVariableon every template that uses either construct.UnusedForeachValueis 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@phpand an unused foreach key also report asUnusedVariableand are silenced along with the compiler noise.UnevaluatedCode, but only the message Psalm uses for code followingreturn/throw/continue: a@switcharm’s@breakleaves Psalm treating the following@case/@defaultline as unreachable, and that specific message is dropped.UnevaluatedCodeis not one shape: the same class also reports'gettype cannot return this value'for agettype()comparison Psalm can prove impossible, which is a genuine author bug and is never gated byfindUnusedVariablesAndParams, 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 areturn/throw/continueinside@phpor raw PHP in the template gets the identical message and is silenced along with the compiler’s@breakshape. 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’sUse of $this in non-class contextandNonStaticSelfCall’sCannot 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::andstatic::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. Theself/staticmatch is case-insensitive but anchored to those two keywords only, never a bare wildcard: Psalm’s own keyword check is case-insensitive too, but itsparenthandling is case-SENSITIVE, soPARENT::x()/Parent::x()outside a class falls through to this identical message shape (Cannot use PARENT outside class context) rather thanParentNotFound. Theparent::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 contextandMethod X::y is not static, but is called using self::, reported only when a@php class ... @endphpblock compiles a real class into the shadow; andInvalid reference to $this in a non-class context(VariableFetchAnalyzer.php’s sibling message for a bare$thisfetch), 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$thisin its own scope either, because ClosureAnalyzer.php only threads$thisinto 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 FLOODINGUse of $this in non-class contextmessage and is still dropped, because the relocator has only the message and location to go on, never scope information. UndefinedThisPropertyFetchon a property of the Livewire component the view’s seed binds$thisto (#1935), matched on the issue’sproperty_idnaming that class. Livewire’s__get()answers#[Computed]andgetXProperty()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 asMixed*. Like the item above, this is the author’s own code, not the compiler’s.$thisinside a class the template declares names that class and keeps reporting. Trade-off: a typo’d$this->propertyin a Livewire view stays silent, as it was before the view was typed.-
RedundantCondition,RedundantConditionGivenDocblockType,DocblockTypeContradiction,TypeDoesNotContainNullandTypeDoesNotContainType, when the message names$attributes,$component,$errorsor$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@propscomponent 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@phpone) keeps reporting even though it shares a class with the dropped shape.$attributesand$slotalso require a component view (see Ambient variables) for this message gate, so a local variable an author happens to name$attributes/$slotin a plain, non-component template is untouched; a plain caller’s compiled$attributescheck is covered by theisset($attributes) && ...item below.$componentdrops 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 compiledresolve()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.$errorsalso drops with no component-view requirement (#1546):ShareErrorsFromSessionruns in thewebmiddleware group, not inrender()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@vardocblock, 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$errorsbefore 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$errorsrequires.Dropping the guard’s own issue does not undo what it leaves behind: the impossible negative arm still merges a reconciled
nullinto$attributes’s type at the guard’sendif, which surfaces downstream as a falsePossiblyNullReferenceon the next$attributes->read.$attributesis therefore re-asserted non-null right after both compiledendifs 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 coverPossiblyNullReferencetoo. 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\ComponentAttributeBaga<x-...>tag compiles twice (theresolve()data merge and the ignored-parameter strip), in any template (#1873). A plain caller’s prelude types$attributesfrom the tag bookkeeping (see Ambient variables), which makes theinstanceofredundant after theisset(). Dropped only in a shadow whose prelude typed$attributesthat 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@verbatimbody, 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 theisset($value)that@session/@contextcompile to save an outer$value(if (isset($value)) { $__sessionPrevious[] = $value; }, #1724). With a$valueof 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 exactisset($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$__errorArgsas the accessed variable AND the offset is the literal1(#1566). A single-argument@error('field')compiles to$__errorArgs = ['field']; $__bag = $errors->getBag($__errorArgs[1] ?? 'default');, and Psalm proves offset1absent 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:@erroris 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 offset1specifically, not any offset on$__errorArgs: a keyed argument (@error(1 => 'field')) compiles to$__errorArgs = [1 => 'field'], which has no key0, and the compiled$__bag->has($__errorArgs[0])read then reports this same class with the offset-0wording, a genuine malformed-input bug (runtimeUndefined array key 0) that keeps reporting. An author’s own out-of-bounds read (@php $arr = ['only']; echo $arr[1]; @endphp) names$arrin the message and keeps reporting too. Two residuals: an author who writes$__errorArgs[1]themselves inside@phpis 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 reportsPossiblyUndefinedArrayOffsetinstead, a different class this gate leaves untouched.PossiblyInvalidMethodCallandPossiblyUndefinedMethodon the synthetic iterator call of a@foreach/@forelseover a union subject (Collection<int, X>|list<X>, #1810). Psalm types such a loop through agetIterator()/current()/key()call on the compiled$__currentLoopDataand suppresses both classes around it, which the remap cannot carry. Matched by position: the synthetic call sits on the$__currentLoopDatavariable, while a real$__currentLoopData->count()sits on the method name and still reports. A raw PHPforeachover the same union inside@phpis not covered.PossiblyInvalidMethodCallon the subject of an array access such as `` where$xor$x['k']may be anArrayAccess(#1909). Psalm analyzes a virtualoffsetGet()/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.PossiblyNullPropertyFetchon$loop->parentand$loop->parent->parent(#1696).$loop->parentstays|nullin 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->parentchain, so a nullable->parenton any other variable still reports. Trade-off: three real null reads are silenced too:$loop->parent->xin an outermost loop, the same read outside any loop (where the prelude declares$loopambiently), and a$loopthe author declares in their own docblock with a nullableparent.-
PossiblyInvalidArgumentandPossiblyFalseArgument, only on argument 1 of ane()orechocall 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` andcompile to byte-identical lines.InvalidArgumentis not included: a definitearrayin an echo position is a guaranteedhtmlspecialchars()fatal, not a possibility, and keeps reporting. Neither is any other argument position: an author’s owne($x, $flag)or@php echo $a, $b; @endphpreports 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:
@@foois unescaped to@foobefore echos are compiled, the##BEGIN-COMPONENT-CLASS##markers are stripped from the finished output after@phpblocks 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 registeredBlade::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-writtene(/echoof 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 aforeachover 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 thatis_string($__key)ternary with the key it narrowed, so the@awareline 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), orImplicitToStringCastfor aStringabledefault. Psalm 7.0.0-rc28 no longer adds anInvalidCastnext to the argument issue for an array, object, or enum default (vimeo/psalm#13036). An all-int-key list (@aware(['color'])) narrows$__keytoneverin the keyed arm and reportsNoValue. Both are dropped only when the issue points exactly at that$__value/$__keyargument and the generated call text up to it ($__env->getConsumableComponentData($__value, or$__env->getConsumableComponentData($__keyfor the keyed arm) does not occur in the raw template, so an author’s own call with the same arguments inside@phpkeeps 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@awareline in that template reports the artifact again, next to the author’s own call. Each arm is switched off only by its own text:$__keycall text leaves the list-form gate on, and$__valuecall 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 thepsalm-laravel-annotatemarker key, on a regular on-disk file (adata://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 (acomposer.json, say) would otherwise arm the codemod AND havepublish()overwrite that file. It is re-checked insidepublish()AND inAnnotationWriter::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 plainvendor/bin/psalmrun 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 thecollectDataIncludeshalf 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\AnnotateCommandrunsvendor/bin/psalmthroughproc_open(theAnalyzeCommandshape), 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--threadsstill 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 whereAfterAnalysisruns. Measured on a scratch project,--threads=4reaches 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, andAnnotationWriter::run()then publishes an error instead of writingmixedover the whole view tree. - Collection.
Blade\Annotate\AnnotationCollector(AfterStatementAnalysis) records producer types through the sameViewCallChain::from()walkViewContractHandlerchecks call sites with.typeFor()answers a type only when every producer of that view agreed (identical types, or a supported covering merge frommerged(): 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 aType::parseString()round trip, carries no template parameter at any depth (aTypeVisitorwalk, becauseUnion::hasTemplate()is false forCollection<int, T>), and cannot close the docblock it is written into (*/). Amixedproducer, an open producer and a type that cannot be safely serialized decline. Literal precision is dropped andint<min, max>is writtenint. - 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 anif/elseif, a loop header and aswitch(with itscasevalues) to the same nested-render scan, since a call there is no statement of its own.isViewBinder()also matches the Factory methodsfirst/renderWhen/renderUnless/renderEach/startComponent*on theViewandFactoryfacades (by theresolvedNamePsalm records, not the spelling) and on a receiver that isview()with no arguments, besides$__env.typeFor()anddisagreeingTypes()answer null for a keyContractRegistry::composedKeys()lists, or for every key when that is null (an unreadable composer), reusing the registry the call-site checks consult. - Deferred edges. An
@includeIsolatedkey passed as a bare variable Psalm saw asmixed(the including template is not declared yet) is stored as a deferred edge inAnnotationCollector::record(), not observed.observations()resolves it at AfterAnalysis from the including template’s per-file plan (AnnotationWriter::fileVariable(),|nullfor an= nulldeclaration) and memoizes per pair; an unresolved edge or a cycle observesmixed. 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-levelmake/renderWhen/renderUnless/firstcall, and every name of a file that may write variables by name (extract,call_user_func,call_user_func_array, a non-name callee, ause functionimport,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 stillmixed, 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) joinsViewReferenceRegistry::templates()(paths, published unconditionally) withContractRegistry::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 withBlade\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, andTemplateAnnotatorreads them withContractParser::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:\wbinds$menout 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::$localVariablesexists for this pass alone: every name the compiled body binds for itself (a@foreachalias, an assignment target, a closure or arrow-function parameter, acatchvariable, astatic/globaldeclaration). The read set deliberately KEEPS them, but declaring one reportsMissingViewDataat every correct call site, so the writer subtracts them, along with$rawDeclaredVariables(already declared). Ause ($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 bynormalizeContract()and recompiled. A variable some closed call site renders the template without is written asT|null $x = null, with no second|nullwhen the type already has one. - Array shapes. Types are written as inferred, so a single call site passing
['a', 'b']declareslist{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 asBladeCompileError; other templates are unaffected.
The following are implementation-specific.
- A nested parameter hides a same-named contract. A template that declares
$titleand also hasfn (string $title) => …(or a closure parameter,catch,staticorglobalof that name) anywhere treats$titleas 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
@extendsis required by the layout, but only the rendered template’s own contract is checked against aview()call site. - View data named
componentis shadowed after a<x-...>tag. Blade uses$componentfor 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$componentis typedYourClass|AnonymousComponentafter it. Pass the value under another name. - A dynamic read of
$componentor$attributessees the bookkeeping type.compact('component'),$$name, orget_defined_vars()in a plain caller reads the name without a$component/$attributestoken, so the prelude still declares itComponent|null/ComponentAttributeBag|null(#1873). A direct$componentread in the same template keeps itmixed. - A non-Blade file in an earlier view root is not seen. With two
config('view.paths')roots, awidget.phpin the first one is what Laravel renders forview('widget'), while the check uses the second root’swidget.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
@phpand raw<?phpblocks 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@casespan 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.ComponentTagCompileremits 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 aprefix: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@endifepilogue consumes an enclosing conditional (the parser only trips where the imbalance becomes unparseable). - A
ParseErrorin 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
@awarelist item is not reported.@aware([null]),@aware([$n])with a nullable$n, and a barenullnext to a keyed pair are silenced along with the compiled@awareloop’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/$attributesmarker, 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$attributesas non-null right after its own save/restoreendifs (see Ambient variables), unconditionally: it cannot tell whether a read that follows comes before or after the marker that makes$attributesgenuinely 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 (orunset()s)$attributesanywhere in its own source: the assertion cannot prove Laravel’sisset($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 undervendor/are not seen, so their views staymixed. 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 thedata()keys at runtime, and the template is still typed as if it did. A template that renders it by name (@includeand 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
falsesentinel@propsdefault types the bodybool.'icon' => falsewith string callers makes `` under@if ($icon)reportInvalidScalarArgument. Declare the real type with a raw<?php /** @var string|false $icon */ ?>. - A
[]@propsdefault types the bodyarray. WithCollectioncallers, an@if ($items instanceof Collection)guard reportsDocblockTypeContradiction. Declare the real type the same way. - Suppressions for dropped findings can report
UnusedPsalmSuppress. A `` comment aimed at a finding the remapper already dropped (anyMixed*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-suppressit reportsUnusedPsalmSuppresson 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(PossiblyUndefinedGlobalVariableandRiskyTruthyFalsyComparisonon$__inertiaSsrResponse, an always-falsyconfig()operand) and Filament’s@endcapture(PossiblyFalseArgumentonob_get_clean()). Each is a handful of findings per app; suppress them per template or in<issueHandlers>.