Skip to content

Changelog

All notable changes to PHPantom will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Added

Changed

Fixed

[0.10.0] - 2026-08-20

Added

Whole-project analysis

  • Full workspace indexing. PHPantom now parses every PHP file in your project in the background after startup by default, building complete symbol data and a cross-file reference index. Find References, Rename, Go to Implementation, and Type Hierarchy resolve against the whole project instead of only the files you have opened, and scan only the files known to reference the symbol. Lighter modes remain available for projects that prefer a smaller footprint. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/186.
  • Workspace-wide diagnostics. Problems can now be surfaced across the whole project rather than only in open files. Set workspace = true under [diagnostics] in .phpantom.toml and, once startup and the background index finish, diagnostics run over every file and stream into the editor's problems panel as they are found, so issues in files you have not opened are already visible when you navigate to them. Configured external tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards. Both passes are deferred until after startup so they never slow down the time it takes the editor to become usable. It is off by default: a project-wide sweep is real work on every session, and it is worth asking for rather than paying for unasked.

Blade templates

  • A Blade template's variables come from a declared priority chain. What a template has in scope is now resolved rather than guessed at. A @bladestan-signature docblock is the template's contract; @props and @aware fill in what it leaves out; a component's own class, and Livewire's $this, supply their members; View::share() and View::composer() registrations in your service providers are read wherever they are written; a layout's declarations reach every template that extends it; and anything still undeclared is inferred from the call sites that render the template. Completion, hover, go-to-definition, and undefined-variable diagnostics all read the same set, alongside the variables Blade itself injects ($attributes, $slot, $componentName, $errors, $loop). Each source only fills in what the ones above it did not declare, so a template that documents its own contract keeps it. Closes #296.
  • Every way of rendering a Blade template is a render site. view(), View::make(), Route::view(), Response::view(), the view factory's first(), renderWhen(), renderUnless(), and renderEach(), a mailable's new Content(view: …) and $this->view(), and Blade's own @include family, @extends, and @each all navigate, hover, complete, and hand the template the data they pass. A render is recognised by its receiver's type rather than by how it is spelled, so a view factory injected into a constructor and a mailable held in a local count too, and a data argument that names nothing (view('page', $data), ->with($extra), array_merge(…)) is read off its type when that type is a single array shape. Contributed by @shuvroroy (#337).
  • A view() call is checked against the Blade template's contract. A template that declares what it needs now holds its callers to it, the same way a function call is held to a signature: a variable the render does not pass, one whose type the declaration does not accept, and a key nothing in the template reads are each reported where the mistake is. This is the editor half of the rule Bladestan (the PHPStan extension for Blade) enforces in CI, so one annotation produces the same errors live while typing and on the build. A template that declares nothing is not checked at all, which is what makes this opt-in, and the checks stand down wherever the data stops being readable. A declaration that widens what its layout declared is reported on the template that writes it rather than at every call site.
  • Blade components are first-class. <x-alert>, <x-forms.date-picker>, and <livewire:counter> resolve to the class or template behind them, so Ctrl+Click opens it, typing <x- or <livewire: completes every component the project ships, and an attribute completes from the component's constructor parameters, its mount() signature, or its @props entries. The tag is then checked as the call the framework makes with it, so a wrong argument type or a missing required attribute is reported on the tag itself, while the attributes Laravel forwards to $attributes are left alone. Components registered by a service provider, addressed by their directory alone, or reached through an anonymous prefix are all found, and $component is bound inside the tag body so completion and hover work on it.
  • A Blade @section knows where its other half is. @yield and @section, @stack and @push, are two halves of one thing written in two files and joined by nothing but a string. Ctrl+Click a name to reach its other half, complete it from what the other half declares, and hover to see which file that is. A @section or @push under a layout chain that never renders it is reported, since it collects content nobody asks for, and the check only runs where the whole render tree can be read.
  • Blade directives complete, and a template's block structure is checked. Typing @ outside an echo, a @php block, or @verbatim completes every directive PHPantom knows, inserting the matching @end… pair with tab stops for one that opens a block. A block closed by the wrong directive, or never closed at all, is reported in the template where it is written rather than surfacing as a parse error in a compiled cache file at a line nobody wrote. Text Blade never compiles, from a @verbatim block to a JavaScript framework's @error="…" attribute, is left alone.

Laravel

  • Laravel's route, config, view, and translation keys are real symbols. Typing inside route(), config(), view(), __(), and the rest of the family completes from the project's own routes, config keys, templates, and translation lines; hover names the key, the file it comes from, and, for a translation, the line it resolves to; go-to-definition jumps to it; and a typo such as route('dashbaord') is reported. Keys and templates registered by installed packages are discovered from their service providers, as are routes wired up from a provider rather than from a conventional routes/ file. Laravel's container attributes and the facade methods that take a config sub-key (DB::connection(), Cache::store(), Log::channel()) complete the same way. Contributed by @calebdw.
  • More of the call sites that name a Laravel key are recognised. A route name is written at far more places than route(), and each of them now completes, hovers, navigates, and is checked: the signed-URL builders, the Redirect, URL, and Response facades, the redirect() / url() / response() helper chains, a form request's #[RedirectToRoute] attribute, and the "is the current route named …?" checks (Route::is() and $request->routeIs()), whose glob patterns are matched the way Laravel matches them. A notification's mail message names a template through view() and markdown() the way a mailable does, Lang::hasForLocale() names a translation key, and Config::getMany() names as many config keys as its array holds.
  • Environment variables are indexed like every other Laravel string key. env('APP_NAME') and Illuminate\Support\Env::get() now complete from the project's .env and .env.example, hover to show the value the variable is set to and the file that declares it, and collect every read of it under Find All References, including the reads inside config/*.php. A name that reads as naming a credential (STRIPE_SECRET, APP_KEY) says only that it is set, so a screen share does not put one on display. Nothing is reported as unknown: the environment a process actually runs with is not on disk, so a name missing from .env proves nothing.
  • Route parameters complete from the route's URI. The keys of route('users.show', ['user' => $user]) are the {parameters} of the URI the route name was declared on, with every prefix that registration inherits, so nested and grouped routes offer the full set. Route::resource() and apiResource() write no URI of their own, so PHPantom now derives the one Laravel derives, and their parameters complete like any other route's. Contributed by @shuvroroy (#301, #308).
  • Artisan command names and signatures. A command's name is recovered from project and vendor command classes however it is declared, and wherever the class lives, so referencing one as a string completes, navigates, hovers with its arguments and options, and is checked, along with the aliases the command answers to. The $signature grammar is parsed too, so inside a command $this->argument() and $this->option() complete against that command's own parameters and are typed by them ({--fresh} is a bool, {--since=} a ?string, {tags*} a list<string>) rather than by the union of every shape a console parameter can take, and the parameter array of Artisan::call() completes the target command's keys. Contributed by @shuvroroy (#274) and @krist7599555.
  • Laravel config values are typed from your config/ files. config('database.default'), Config::get('app.name'), and $repository->get('mail.from') resolve to what the project's config/*.php files actually hold: scalars to their base type, env() defaults through their fallback argument, and nested arrays to array shapes with typed keys. Framework defaults fill in any key a partially published config file leaves unset. Contributed by @calebdw.
  • A Laravel path helper opens the file it names. base_path('routes/web.php'), app_path(), config_path(), database_path(), lang_path(), public_path(), resource_path(), and storage_path() make their argument a clickable link that go-to-definition follows, and typing one completes a segment at a time from the directory the path has reached so far. Contributed by @shuvroroy (#334).
  • Laravel authorization abilities and policies. The ability named in Gate::allows(), $user->can(), $this->authorize(), a can:ability,Model middleware parameter, and Blade's @can / @cannot / @canany now completes, hovers, navigates, and is checked. Abilities come from Gate::define() registrations and from the public methods of a model's policy, with the policy found the way Laravel finds it. When a check names its model the ability is validated against that model's policy, so an ability belonging to a different model is reported as such rather than as a typo. Contributed by @shuvroroy (#330).
  • Laravel container bindings resolve to the class they bind, and so do the facades built on them. A string key registered in a service provider (singleton(), bind(), instance(), alias(), or the $bindings / $singletons arrays) is now indexed, so app('sentry') and resolve('sentry') resolve to the bound class, the key hovers and navigates to the registration that declares it, and find-references collects every call that asks for it. When several providers bind the same key, the key resolves to the class the container would end up holding. Most facades name such a key rather than a class in getFacadeAccessor(), and a facade written by hand ships no generated @method docblock at all, so both used to list nothing useful; each now takes its members from the class behind the accessor, and a fluent method continues the chain on that class. Contributed by @shuvroroy (#335).
  • Eloquent models know their database columns. PHPantom now reads both the schema dumps under database/schema and the project's migrations, wherever they live, and turns the columns they describe into model properties carrying database types, nullability, and defaults. Lookup respects $connection, $table, the Laravel Connection and Table attributes, and dynamic overrides, and migration scanning is incremental, so editing one migration replays a cached plan rather than re-reading the rest. Custom column helpers registered with Blueprint::macro() are understood. Configure with [laravel.migrations] in .phpantom.toml. Contributed by @calebdw.
  • An Eloquent model factory resolves what it actually builds. The dynamic has{Relationship}() and for{Relationship}() methods Laravel resolves through Factory::__call() now complete, hover, and chain, one per relationship on the associated model, alongside trashed() for a model using SoftDeletes. The count travels with the factory too, so count(3), times(3), and factory(3) switch create() and make() over to the collection the model really builds, custom Eloquent collections included, while a chain that sets no count stays a single model. Contributed by @shuvroroy (#260, #315).
  • Laravel request input and validated() are typed from the validation rules. A rules array is the complete set of inputs a request may carry, so its keys now complete wherever a request accessor names a field, each shown with its rule and navigable to the line that declares it. validated() becomes a real array shape rather than the bare array it is declared as: $data['title'] is a string, a nullable field adds null, one that is neither required nor nullable becomes an optional key, 'items.*.id' becomes list<array{id: int}>, and an image rule gives you a real UploadedFile. An enum rule types its field as the enum's backing type. Rules reached through array_merge(), the parent chain, or a trait are all followed. Contributed by @shuvroroy (#292, #294, #307).
  • Higher-order Laravel collection proxies. $users->map->email is Laravel shorthand for $users->map(fn ($u) => $u->email), and it is now typed that way: the item type's members complete and hover through the proxy, and each resolves to whatever the proxied collection method returns for it. The result is an ordinary collection, so the chain continues from it, and a method returning static stays on the Eloquent or application-defined collection it came from. Contributed by @shuvroroy (#314).
  • Eloquent $pivot on many-to-many related models. A model reached through a belongsToMany or morphToMany relationship now exposes $pivot, so the intermediate row completes, hovers, and resolves. The type comes from the relationship's TPivotModel generic, then a ->using() call, then the base Pivot, and the relationship's ->withPivot() columns are shown on hover. Contributed by @shuvroroy (#266).
  • Eloquent morph map aliases. A Relation::morphMap([…]) or enforceMorphMap([…]) call in a service provider is recovered, so the short aliases it registers behave like real symbols: hover names the model an alias maps to, go-to-definition offers both the registration and the model, and find-references links every usage back. Every position Eloquent resolves through the map is recognised, and an unregistered alias is only reported where the project enforces the map, since that is the only case where the set is exhaustive.
  • Storage::disk() resolves to the concrete adapter. The manager's disk(), drive(), cloud(), and build() declare only the Filesystem contract, so adapter-only members such as assertExists() and download() were reported missing. config/filesystems.php is now read to see what each disk is really built from, and a disk on a custom driver is resolved through the Storage::extend() registration in your service providers rather than costing every other disk its type.
  • Laravel and Carbon macros registered with mixin(). A Str::mixin(new StrMixin()) or Collection::mixin(CollectionMixin::class) call contributes one macro per public method of the mixin, taking the signature of the closure that method returns, so those methods complete, hover, resolve, and type-check. Carbon's trait-based mixin() is read the way Carbon reads it, where the trait's methods become methods on the target directly. Contributed by @shuvroroy (#256) and @calebdw.
  • Larastan's model-property<Model> is checked and completed. The pseudo-type is resolved against the model's known properties during argument checking, so a string literal that names no property is flagged, and typing inside such an argument completes the model's property names. Contributed by @calebdw.

Diagnostics

  • Two new diagnostics: illegal readonly writes and self-contradicting docblocks. A write to a readonly property from anywhere PHP forbids one, and a @param or @return tag that contradicts the nullability of the declaration it documents, are now reported where you write them rather than when the code runs. Every form the readonly write can take is checked, including the ones that are easy to overlook (unset(), a foreach or destructuring target, taking a reference), and the writes the language allows are left alone.
  • Four new declaration diagnostics. An enum whose cases do not agree with its backing, a redeclaration that drops static from an inherited return type, an abstract trait method nothing implements (with the "Implement missing methods" code action stubbing it alongside the rest), and a match arm whose literal can never equal the subject. Contributed by @calebdw.

Type inference

  • preg_match() fills $matches with the keys the pattern actually has. A literal pattern now gives the result an array shape: key 0 for the whole match, one per capture group, and a named group under its name as well as its number, with the trailing groups a successful match can leave out marked optional. PREG_OFFSET_CAPTURE and PREG_UNMATCHED_AS_NULL are honoured, and preg_match_all() reads the same way, holding each group as a list or one shape per match under PREG_SET_ORDER. The condition that tests the call's result decides which of the two states a branch is looking at, so inside the guard a group read is a string, the branch that runs on a failed match gets the empty array PHP leaves behind, and where the two rejoin the keys are marked as ones that may be missing.
  • By-reference closure captures update the outer variable. A closure passed to a callable parameter can now update the inferred type of a variable captured with use (&$var) when the callable is considered immediately invoked, following PHPStan's defaults for which parameters those are. Contributed by @calebdw.
  • A call can retype the variable it was called on. @psalm-this-out and @phpstan-self-out say that calling a method rebinds the receiver's own template arguments, which is how a mutable generic container describes swapping its contents for a value of another type. The receiver is now retyped from the call the way an assignment retypes a variable, and members written in terms of the class's template parameter follow it.
  • @phpstan-require-implements contributes to trait $this resolution. A trait annotated with the tag now resolves $this against the required interface inside its own methods, matching the existing @phpstan-require-extends behaviour, so the required members are available in completion, hover, and member resolution while editing the trait. Contributed by @calebdw.

Editing and navigation

  • Override completion. Triggering completion at the class body root, or after function, $, or const, now offers every parent, interface, and trait member the class can still override or implement, each inserting the complete declaration rather than a bare name. Snippets add #[\Override] on the PHP versions that accept it for that kind of member, carry readonly through, and skip final and private members and anything the class already defines. Contributed by @calebdw.
  • Reference and implementation counts. Classes, interfaces, traits, enums, methods, properties, constants, and standalone functions now show how many times they are used, as an inlay hint and above the declaration, with implementation counts on interfaces and abstract classes. Counts come from the same search Find References runs, so following one lists exactly what it counted. Contributed by @calebdw and @petrovo-as.
  • PHPUnit coverage metadata navigates both ways. Ctrl+Click a target in #[CoversClass], #[CoversMethod], #[CoversFunction], their Uses counterparts, or the older @covers, @uses, and @coversDefaultClass annotations, and you land on the declaration it names; rename keeps that metadata in step. In the other direction, a class any test declares coverage for carries a lens naming the tests that cover it, which is the direction you want when you are about to change something.
  • Two new code actions. "Sort use statements" re-sorts a file's imports the way PhpStorm's Optimize Imports does, keeping use, use function, and use const apart, respecting a blank line as a group boundary, and moving an attached comment with its import. "Convert to string interpolation" rewrites 'Hello ' . $name . ', welcome!' into "Hello {$name}, welcome!", re-escaping the literal text for its new quoting and holding back wherever interpolation would change the result or read worse.
  • Semantic token modes. .phpantom.toml now supports [semantic_tokens] mode = "contextual" | "full" | "off". The default contextual mode emits only context-sensitive highlighting that complements editor syntax grammars, while full keeps the previous broad stream and off disables semantic tokens. Contributed by @calebdw.
  • @phpstan-ignore identifiers are highlighted and completed. The tag and each listed error identifier are highlighted in both docblocks and ordinary // comments, and identifier completion works inside the comma-separated list, drawing on the diagnostic codes already seen in the current file. Contributed by @calebdw.

Tooling and platform

  • Settings you want in every project can be set once. phpantom_lsp init --global creates a config in your platform's config directory (~/.config/phpantom_lsp/.phpantom.toml on Linux) that every project inherits, so a preference like turning workspace diagnostics on, or pinning a PHP version, no longer has to be repeated in a .phpantom.toml per repository. It takes the same keys as a project config and is read first; a project's own config is merged over it key by key rather than replacing it, so a project only has to spell out the settings where it differs from your defaults. A mistake in the global file is now reported against that file instead of against the project config it was merged into.
  • Config changes apply without a restart. Editing either the global config or a project's own .phpantom.toml now reloads settings within a couple of seconds, for both new and existing editor windows. Previously a project's own config only reloaded for Laravel projects, and the global config never reloaded at all.
  • Analyze verbosity flags. phpantom_lsp analyze now supports PHPStan-style --debug and -v/-vv/-vvv. --debug prints each file as it is analyzed and disables the progress bar, so a hang is immediately attributable to a specific file; -v adds per-file durations and a phase summary, -vv adds worker ids and parse tracing, and -vvv adds memory usage.
  • PHPantom can run in the browser. The whole type engine compiles to WebAssembly, so a web editor can have PHPantom's completion, hover, go-to-definition, symbol highlighting, and rename without a server to talk to and without a round-trip per keystroke. The module speaks ordinary LSP JSON-RPC and needs no filesystem: the standard library stubs are compiled in and open documents live in memory. Every release ships a prebuilt module, so a host can pin a version rather than build its own. See wasm.md for the host interface. Contributed by @ondrejmirtes.

Changed

Behaviour

  • PHPDoc comments and the types inside them are now parsed by one unified parser. Tags written with a @psalm- or @phpstan- prefix are recognized as the same tag as their unprefixed form throughout, so a vendor-prefixed variant reliably takes precedence over the plain one, and spellings such as @phpstan-extends, @phpstan-sealed, and @template-extends are understood everywhere the plain spelling was. Variance annotations (covariant, contravariant) are parsed directly rather than stripped beforehand, which makes docblock go-to-definition and rename land on the right text. Tags are read from the parsed grammar instead of being scanned again as text, so a type written across several lines resolves like its single-line form and trailing prose no longer leaks into a @phpstan-type alias or a @method parameter type. A tag indented with more than one space after the * is no longer dropped, a docblock you are still typing now yields the tags above the cursor instead of nothing, and anything the grammar cannot parse still falls back to the old scan.
  • Property hover now shows effective types as a var detail line. Property hovers mirror method hovers by displaying the resolved type above the PHP snippet as **var**, while the snippet itself shows only the native declaration. This keeps docblock-inferred, virtual, and schema-derived types out of the generated signature block. Contributed by @calebdw.
  • Continuous progress reporting. The indexing progress bar now advances file by file with live counts (e.g. "Scanning vendor packages (3201/8544 files)") instead of jumping between a few fixed milestones. This covers single-project, monorepo, and non-Composer workspaces. Go to Implementation, Find References, and Type Hierarchy show the same live progress while they scan, including when one of them triggers the first full workspace index.
  • One unanalysable file no longer stops workspace diagnostics. The project-wide scan used to work through the file list in blocks and wait for a whole block to finish before starting the next, so a single file the type engine could not get through took the rest of the project with it: the progress bar froze on the count the last completed block ended at (always a multiple of 128, which is why it looked like the scan stopped at a third of the way through) and every file still queued behind it went undiagnosed for the rest of the session, with nothing reported to say why. Files are now handed out one at a time, so the count advances continuously and the other workers keep going regardless. A file still being analysed after two seconds is named in the progress message, and one that reaches ten is given up on: the scan moves on to the remainder of the project, and the file is named in the editor's log along with where to report it, since a file that slow to analyse is a bug in PHPantom rather than a file that is merely large. Being given up on costs that file nothing lasting, because opening it diagnoses it through the live pipeline anyway. Closes #361.
  • A project-wide external tool re-run no longer invalidates every file it reported. PHPStan, PHPCS, or Mago analysing the whole project used to bump every file's cached diagnostics whether or not that file's results actually changed, so the next workspace pull re-sent the full diagnostic set for the entire project even when only one file was affected. Only files whose diagnostics actually differ from the previous run are now invalidated.
  • Updated the bundled mago toolchain to 1.46.0. The parser, docblock parser, formatter, and supporting crates are refreshed to the latest upstream release. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/234.
  • Updated embedded phpstorm-stubs. Brings PHP 8.6 stub coverage, corrected Redis, FFI, enchant, and xmlreader signatures, an openssl_x509_parse() return type fix, and updated default flags for htmlspecialchars()/htmlspecialchars_decode().

Performance and memory

  • Saving a file no longer re-analyses every other open tab. A save used to re-run the full diagnostic pass, the most expensive thing PHPantom does, on all open files in case any of them depended on the saved one. It now works out what the save actually changed and re-analyses only the open files that mention one of those names, so unrelated tabs are left alone and completion and hover stay responsive right after a save. Cases the comparison cannot narrow, such as saving a Laravel config, translation, or route file, still refresh every open file as before.
  • Classes are pre-resolved for the whole workspace after startup. Once the background index completes, every known class is resolved in dependency order even when workspace diagnostics are disabled, so the first completion, hover, or go-to-definition against any class reads a warm cache instead of resolving on demand. That pass now spreads across multiple workers rather than running on a single one, substantially cutting the pause between indexing and diagnostics on large Laravel projects. Edits still re-resolve only the affected classes.
  • Lower memory use for stored types. Every parameter, return, and property type now takes less than half the memory it used to, and identical types (every string parameter, every ?Carbon property, every Collection<User> return) are stored once and shared instead of duplicated at each occurrence. Comparing two types is now a quick reference check rather than a walk over their structure, so analysis is slightly faster too. On large Laravel projects this meaningfully cuts both peak memory and live heap size, with no change to what PHPantom resolves.
  • Lower memory use when resolving class hierarchies. Resolving a class no longer copies every inherited or synthesized method, property, and constant onto it. Members a merge doesn't actually change are shared with their source across the whole workspace, and members it does produce are deduplicated so identical results share one allocation. On large Laravel projects, where most Eloquent models resolve through a shared generic base, this roughly halves the memory held by the resolved-class cache and speeds up resolution itself.
  • Lower memory use in the cross-file reference index, method lookups, and member access spans. The index backing Find References and the reference-count inlay hints now keeps only the data each symbol actually needs, each resolved class's method lookup uses a more compact structure, and the text recorded for every ->/:: access reuses the source file's own bytes instead of allocating a copy in the common case. On large projects these together remove a large share of short-lived allocations, with no change to any feature's results.
  • Project startup is significantly faster. Building the class index now reads files normally instead of memory-mapping them, and every part of startup that still ran on a single core uses all of them. Every autoload directory, your own and each vendor package's, is walked in one pass that shares work between cores at the directory level, so a single very large dependency no longer holds up the rest of the scan, and the ignore rules above those directories are compiled once for the whole project instead of once per package. A bundled tool archive such as PHPStan's .phar is read through a memory map with only its file index retained, rather than copied into memory whole. On a large Laravel project this cuts the indexing phase by roughly a quarter and lowers peak memory by around 25 MB. Discovered files are now sorted, so when two files declare the same class name the one that wins is the same on every run instead of depending on the order the filesystem happened to return.
  • Faster class-name resolution. Looking up which class a name refers to is the single most frequent thing PHPantom does, so repeated lookups are now cached and invalidated only when the class indexes actually change. Whole-project analysis is 8-12% faster on large Laravel projects with lower CPU use and no change in results; hover, completion, and go-to-definition resolve names through the same path and see the same improvement.
  • Vendor package scanning no longer reads every file twice. Startup used to scan each vendor file once to find its classes, functions, and constants, then read and scan it again just to classify which package it came from for completion ranking. Both are now done in a single pass, roughly halving the I/O and CPU cost of the vendor scan.
  • Class origin classification no longer re-scans the whole classmap after the fact. A class's completion-ranking origin used to be worked out by re-reading and re-parsing installed.json a second time and prefix-matching every class's file path against the package list on a single thread. The origin is now attached to a class the moment it is discovered during the already-parallel vendor scan, the same way it already worked for functions and constants.
  • Argument checking no longer slows down quadratically with file size. The argument-count and argument-type checks know the byte offset of every call they inspect, but used to convert it into an editor line/column position and immediately back again before looking up the called function. Each of those conversions re-read the file from the beginning to count characters, so a file with thousands of calls spent nearly all its time on offset arithmetic. The offset is now used directly. On a 370 KB file containing 2200 calls the two checks together drop from 13.7 seconds to 0.2, taking the whole file from 16.7 seconds to 3.4.
  • Faster diagnostics on long method chains. Resolving a -> chain caches each link so a prefix shared with the next call is only worked out once, but the key each link is cached under was built for every link up front, and building one means writing out the whole sub-expression it stands for. On a fluent chain that cost grew with the square of the chain's length, and nearly all of it was thrown away. Keys are now built only as far as the lookup actually reaches, so a file built from long chains reports its diagnostics around a quarter faster and the deprecated-usage check roughly halves.
  • Faster diagnostics on large projects. Several diagnostic checks (by-reference parameter detection, the Stringable-to-string acceptance check, and model-property<Model> literal validation) now reuse a class's already-resolved inheritance instead of re-merging traits, parent classes, and generics on every call. As a side effect these checks now also see interface-declared members.
  • Faster diagnostics on method/function calls that resolve to no concrete class. Checking whether such a call's result was actually a bare object/?object used to re-resolve the callee's whole receiver chain and method signature a second time from scratch. That check now reuses the resolution already performed, roughly halving diagnostics time on files with many unresolved or missing-method call chains.
  • Faster assert()/type-guard narrowing during the forward walk. Every statement used to build a fresh resolution context (including a scope clone) for each in-scope variable to check whether it was an assert() or @phpstan-assert/@psalm-assert call, even for statements that could never be one. Non-call statements now skip that work entirely.
  • @method and @property tags are parsed once per class instead of on every resolution. The magic members a class declares in its docblock are now parsed when the file is read and reused from then on, instead of being re-parsed from the raw comment text every time the class (or anything that inherits or mixes it in) is resolved. Whole-project analysis of a large Laravel codebase runs a few percent faster and uses slightly less memory, with identical results.
  • Faster Eloquent scope-method resolution. Injecting a model's scope methods onto its Builder used to re-walk the model's full inheritance chain from scratch on every Builder<Model> instantiation. That base resolution is now cached, so a file with many instantiations of the same model's Builder resolves its scopes once.
  • Faster workspace symbol search. Matching a symbol against the "Go to Symbol in Workspace" query no longer allocates a lowercased copy of every class, method, property, constant, and function name in the project on each keystroke; matching is now done byte-wise in place for the common case of ASCII identifiers.
  • The analyze and fix CLI subcommands no longer build the cross-file reference index. That index only serves Find References, Rename, and reference-count inlay hints, none of which the CLI subcommands query, so skipping it removes wasted work from whole-project runs. The editor's LSP session is unaffected.

Removed

  • Bundled Zed extension. PHPantom's plain-PHP wiring has merged into Zed's official PHP extension, so a separate PHPantom extension is no longer needed. See Editor Setup for the updated Zed configuration.
  • Linked editing. Editors mirror keystrokes into a linked range on trust, and that turned ordinary manual edits into buffer corruption: rewriting $this->someMethod($comment->createdByUser) into an extracted variable while the cursor sat inside a linked range for $comment truncated the new line into $author = $->createdByUser;, mirroring a deletion the user never intended to repeat. Use textDocument/rename (explicit, cross-file, previewable, one undo step) or your editor's multi-cursor instead.

Fixed

  • Go-to-definition on a Blade echo delimiter agrees with its hover. {{ }} compiles to a call to e(), which is not written anywhere in the template, and hovering the {{/}} itself already reflected that by describing the implicit e() call. Ctrl+Click on the same character disagreed: it fell through to the underlying PHP expression and landed on whatever the delimiter happened to sit next to, such as route(...) in {{ route('pages.index') }}. It now targets e() too.
  • A method chain no longer resolves against another file's use import. The cache that reuses a shared chain prefix (Pen::make() in Pen::make()->write()) keyed its entries by the chain's text alone, with nothing to tell two files apart. A background scan that walks many files under one cache activation, such as Find References or the reference-count computation behind the inlay hints, could resolve a chain in one file against a same-named class a different file imports under the same alias (use A\Pen; in one, use B\Pen; in another), undercounting or overcounting references depending on which file the cache was populated from first. Each file's chains are now cached separately.
  • A route group whose name is entirely a variable no longer flags its own routes as unknown. Route::name($panelId)->group(...), written with no enclosing named group, is how Filament wires up its panels, and the route names it registers cannot be enumerated statically since $panelId is not known until runtime. With no known prefix to anchor a check against, every route() call naming one of those routes was reported as unknown. The route names actually written inside such a group are now recognised as unjudgeable by the text after the unknown segment, so a call like route('filament.admin.pages.dashboard') is left alone while unrelated typos elsewhere in the project are still caught.
  • Unsetting an array element is now understood as narrowing the array, not just the variable. unset() only ever cleared a whole variable, so unset($config['driver']) on a non-empty-array or a shape with a required driver key left that guarantee in place. A foreach reading the same array afterwards was still treated as certain to run at least once, so a sentinel value seeded before the loop ($driver = null;) was reported as impossible to be null even though the array could since have emptied out. Unsetting a known key now drops it from a tracked shape, and unsetting any key at all now drops the non-empty-array/non-empty-list guarantee, so the loop is correctly treated as possibly not running.
  • A class's own offsetGet() is trusted over ArrayAccess's own docblock. ArrayAccess documents offsetGet() as returning TValue, a placeholder name that only means something once a class writes @implements ArrayAccess<TKey, TValue> to bind it. A class that implements ArrayAccess natively, with no generics at all, still had that placeholder leak in as if it were a real class named TValue, so $pens[0]->write() reported write as unresolvable even though offsetGet(): Pen was declared right there. $pens[0] now resolves to what the class's own offsetGet() declares.
  • An accessor that returns whatever its arguments name is typed at each call site. A method whose result depends on the values it is handed can say nothing useful in its signature, so a reflection-style accessor is written with an untyped parameter and, at best, a @return mixed. Its result used to be mixed everywhere, which cost the value its members, its hover, and its place in Find References and rename. Reading such a method's body now starts from the types the call site passed rather than from what the signature declares, so Sudo::fetchProperty($config, 'shell') resolves to the type Configuration::$shell declares, its members complete, and the constants and methods read off it count as references to the class they belong to. A call that decides nothing keeps the declared type, and a body is only read where the declaration left the answer open or a call's arguments make it strictly more specific.
  • A workspace diagnostics scan finishes the project even when files wedge it. The scan gives up on a file that takes more than ten seconds and moves on, but the worker that was on it used to be written off with it. On a four-core machine the pool is only two workers wide and the file list is sorted, so two neighbouring files could take the whole pool down within seconds of each other and every file behind them in the queue went unchecked for the session. Each retired worker is now replaced, so a handful of files the type engine cannot get through costs their own analysis and nothing else. A file that finished just as its deadline passed is also no longer reported as abandoned, and the file its worker had already moved on to is no longer dropped.
  • Switching workspace diagnostics on takes effect without a restart. workspace = true under [diagnostics] was only read at startup, so enabling it in a project's .phpantom.toml or in your global config mid-session did nothing until the editor was restarted, contrary to the rest of the configuration. Saving the change now starts the scan.
  • Switching workspace diagnostics off now takes effect immediately too. Setting workspace = false mid-session left a scan already in progress to run to completion and publish results for files you had just said you did not want diagnosed, and every result the scan had already reported stayed in the problems panel, kept up to date on every file you closed, for the rest of the session. Saving the change now stops a running scan and clears everything it had reported. Turning it back on afterwards starts a fresh scan, the same as enabling it for the first time.
  • A busy editor no longer parks a background task for the session. Asking the editor to re-pull diagnostics is a request the server waits on an answer to, and the requests sent after a batch of watched files changed (a branch switch) and after the background index finished waited without a time limit. Both are best-effort, so both now give up after ten seconds like the rest.
  • A function call written in a different case is found and renamed. PHP resolves function names case-insensitively, so HELPER() and helper() call the same function, but find-references and rename compared the two exactly. A call spelled in another case was missing from the results, and renaming the function left it calling a name that no longer exists, breaking the very file the rename was meant to update. Every spelling now matches, in the cross-file index that decides which files to scan as well as in the scan itself. Constant names stay case-sensitive, which is how PHP treats them.
  • A stored preg_match result keeps the groups it matched. preg_match writes its capture groups into an out-parameter, and a literal pattern says which keys that leaves behind, so a group read resolves to a string. Storing the call's result in a variable first ($ok = preg_match($pattern, $html, $m);) lost all of it: the array kept whatever was there before the call, so a group read came out as null and every function it was handed to reported an argument-type error. The call is now recognised whether or not its result is stored, and the variable holding the result stands for the match, so if ($ok) narrows the array to the pattern's keys and the else branch to the empty one, exactly as testing the call itself does. This applies to by-reference output parameters generally, not just preg_match: a call that writes through a reference is seen when its result is assigned too. A variable that is both the target of the assignment and the out-parameter of its call ($file = end($file);) still holds what was assigned to it, since the assignment happens once the call has returned.
  • A class written in a different case is found and renamed. PHP resolves class names case-insensitively, so new WIDGET() instantiates Widget, but find-references and rename compared the two exactly. The mis-cased site was missing from the results, and renaming the class left that file instantiating a name that no longer exists, breaking the very file the rename was meant to update. Every spelling now matches, in the cross-file index that decides which files to scan, in the scan itself, and through the use imports, aliases, and collision handling the class rename goes through. Starting the rename from a mis-cased site also works: the old name is read off the declaration rather than off the site the cursor was on, so the file is still renamed alongside the class it holds. The same applies to new expressions found as references to a constructor. Constant names stay case-sensitive, which is how PHP treats them.
  • Rename can be started from a fully-qualified call site. \Support\shout() names its function with a leading backslash, and the check that guards rename against a symbol map older than the buffer compared that text against a name recorded without one. The comparison could never match, so prepare-rename returned nothing and rename refused to run from that spelling, even though a rename started from any other site rewrote the same call correctly.
  • A global function called from namespaced code reports how many times it is used. The reference count above a function declaration read "0 references" for a global helper whenever its callers were namespaced, which is every PSR-4 project with a helpers.php. PHP qualifies an unqualified call with the current namespace and falls back to the global function when nothing is declared there, so a helper() call inside namespace App; was credited to App\helper alone and the declaration it actually reaches was credited with nothing. Both names such a call can reach are now counted, while a call reached through a use function import still counts only towards what it imports.
  • Renaming a define()-declared constant rewrites the define() call too. The name a define('FOO', 1) call declares is a string literal, and nothing recognised it as naming the constant it creates. Renaming FOO from any use site therefore rewrote every use and left the define() call declaring the old name, so the code no longer defined what it read. The call is now the constant's declaration site: rename reaches it, find-references lists it, and hover and document-highlight work on the name inside the quotes. Go-to-definition on a use of such a constant now lands on the name itself rather than on the define keyword.
  • Renaming a constant now rewrites defined() and constant() calls too. defined('FOO') and constant('FOO') name a constant through a string literal the same way define() does, and nothing recognised either as a reference to it: renaming FOO rewrote the declaration and every ordinary use but left these calls asking about the old name, so a defined() guard silently stopped guarding and constant() failed at runtime. Both now resolve, hover, navigate, and are reached by rename and find-references like any other use of the constant. constant('Foo::BAR'), which names a class constant rather than a global one, is left alone.
  • A property read back through reflection types as the property declares. getProperty() is declared to return a bare ReflectionProperty and getValue() a bare mixed, which is as specific as an annotation can be: what the read produces depends on the name passed to getProperty(), not on any type. Where that name is a literal and the reflected class is known, the read now resolves to the declared type of that property, so (new ReflectionObject($config))->getProperty('shell')->getValue($config) completes, hovers, and navigates as the ?Shell it is, and a member reached through it counts as a reference to that member. new ReflectionObject($x) also keeps the class it reflects, the way new ReflectionClass($x) already did, so newInstance() on it no longer widens to object. A name that is not a literal, a property with no declared type, and a reflected value whose class is unknown all keep mixed.
  • Renaming a namespaced constant or function no longer renames an unrelated symbol of the same short name. Find References and rename matched a call or a constant reference against the target whenever the two merely shared their last name segment, so renaming App\A\VERSION also rewrote an unrelated App\B\VERSION declared in a sibling namespace, and the same happened for functions. The short-name check exists to catch PHP's own fallback from an unqualified reference to the global constant or function of that name, and it still does, but only once the namespace-qualified guess is confirmed to name nothing real; a namespace-qualified target, or a sibling namespace that genuinely declares its own symbol under the same short name, is no longer treated as a match.
  • Closing a file during a project-wide PHPStan/PHPCS/Mago scan no longer resurrects its diagnostics. A whole-project run of an external tool feeds an open file's results straight into the live per-file cache so the buffer updates immediately, but it only checked whether the file was still open before starting that hand-off, not right before writing it. Closing the file while the scan was still delivering its other results left the file's problems reappearing in the editor even though it had just been closed and its cache cleared. The open check now happens immediately before each write, matching how the per-file PHPStan/PHPCS/Mago runs already behave, and an open file whose issues the scan no longer reports now has its cache cleared too instead of keeping stale results.
  • A project-wide PHPStan/PHPCS/Mago scan no longer undoes a result it was overtaken by. These project-wide runs take long enough that a file you are editing can be re-checked on its own, and finish, while the scan is still delivering. The scan then wrote its own findings over the newer ones, so a problem you had just fixed came back and stayed until the next single-file run. Each tool now tracks which files it has re-checked since a scan began and the scan leaves those alone, keeping whichever result was produced from the newer content.
  • is_a($x, Foo::class, true) keeps the string half of an object|string parameter. The third argument to is_a() means the check also passes when $x is a class-string<Foo>, not just an instance, but a successful check replaced the whole subject type with Foo alone whenever nothing in the union already named a class. A route parameter typed object|string came out of the check as plain Foo, so a nested is_string($x) guard written to handle the class-string case was reported as always false. The check now narrows to Foo|class-string<Foo>, keeping both halves the third argument actually allows.
  • get_class($v) !== Foo::class keeps the subclasses it lets through. A negated exact-class check was applied as if it were !($v instanceof Foo), so every subclass of Foo was ruled out along with Foo itself. A subclass's get_class() names the subclass, so it passes the comparison and belongs in the result: filtering a Dog|Puppy|Cat list on get_class($v) !== Dog::class now keeps Puppy instead of reporting Cat alone.
  • An instanceof check keeps the type arguments of what it narrows. Narrowing a union member that already named the checked class replaced it with the bare class, so array_filter($items, fn ($v) => $v instanceof Collection) on a Collection<User>|string list came out as Collection and everything the chain after it read was untyped. The member is the more specific of the two and now survives intact, <User> included.
  • !($v != null) is no longer read as proof that $v is null. Loose equality also matches '', 0 and [], which is why $v == null narrows nothing, but the negated spelling of the same comparison was treated as the strict one and narrowed the value to null. It now narrows nothing either. !($v === null), which does prove the value is not null, is recognised as well, where before it said nothing at all.
  • A static call on a static property is no longer mistaken for a property hook. parent::$prop::get() is PHP 8.4's spelling for invoking a property's overridden hook, and any class in that position was being rewritten as one. Registry::$instance::get('service'), which is an ordinary static property holding a class name followed by an ordinary static call, therefore lost the call entirely: hover and go-to-definition on get answered nothing, its unknown-method and argument-count checks never ran, and hover showed the static property as an instance one. Only parent is treated as a hook invocation now.
  • The string refinements a docblock declares are no longer discarded. non-empty-literal-string, uppercase-string, non-empty-lowercase-string, non-empty-uppercase-string, trait-string, and enum-string were not recognised as refinements of string, so a @param carrying one was thrown away in favour of the bare string the parameter declares natively, and every check on such a value was skipped as if the type named a class that could not be found. They now hover, narrow, and are checked like the rest of the family. non-falsy-string also no longer accepts the non-empty-… refinements, each of which still admits the falsy "0".
  • A literal string satisfying lowercase-string or uppercase-string is no longer rejected. Recognising those refinements (above) exposed a gap right behind it: a string literal compared against lowercase-string, uppercase-string, either non-empty- variant, or callable-string matched none of the literal-value rules and fell through to "not a subtype", so 'abc' failed a lowercase-string parameter and 'strlen' failed a callable-string one, the opposite of what both refinements allow. A literal now satisfies lowercase-string/uppercase-string exactly when it has no cased character the wrong way (so '123' and '' satisfy both), and a callable-string literal is accepted outright, since proving a function name doesn't exist needs the symbol table this type-level check cannot see.
  • Constructor references include new self() and new static(). Finding references on a __construct declaration found new Foo() call sites but skipped instantiations written through the self, static, and parent keywords, so a class that builds itself internally under-reported: return new self(); inside a static factory method never showed up. The constructor's own class is now resolved from the enclosing declaration the same way self::__construct() already was, without also double-counting the same keyword when it is the subject of a static call rather than the operand of new.
  • A Laravel project that requires Larastan gets PHPStan diagnostics too. PHPStan auto-detection looked only for a direct phpstan/phpstan dependency in composer.json, so a project that requires larastan/larastan and lets it pull phpstan/phpstan in transitively never had vendor/bin/phpstan recognised, even though the binary was right there. A Laravel project is now recognised through a direct dependency on larastan/larastan, or a fork of it such as calebdw/larastan, instead: plain PHPStan does not understand Eloquent magic, facades, or container bindings, so a Laravel project that depends on phpstan/phpstan directly but has not installed Larastan is still left alone rather than run through an analyser that would misread its own framework.
  • A project with its own phpstan.neon gets PHPStan diagnostics regardless of what composer.json declares. PHPStan auto-detection depended entirely on a composer.json dependency, so a project that hand-authors a phpstan.neon or phpstan.neon.dist config, whether it depends on phpstan/phpstan transitively, installs a Larastan fork the dependency check does not otherwise certify, or wires PHPStan up some other way entirely, never had vendor/bin/phpstan recognised. A phpstan.neon/phpstan.neon.dist file at the workspace root is now itself enough to certify PHPStan on a project, including a Laravel one that has not installed Larastan.
  • A project with its own phpcs.xml gets phpcbf as its formatter, even when squizlabs/php_codesniffer is only a transitive dependency. Formatter auto-detection looked only for a direct squizlabs/php_codesniffer entry in require-dev, so a project that instead depends on a rules package like slevomat/coding-standard or cakephp/cakephp-codesniffer, which pull PHP_CodeSniffer in transitively, never had vendor/bin/phpcbf recognised even though the binary was right there. A phpcs.xml, .phpcs.xml, phpcs.xml.dist, or .phpcs.xml.dist file at the workspace root now certifies phpcbf on its own. Closes #374.
  • A project that formats with Mago keeps formatting with Mago. PHP_CodeSniffer is a linter that happens to ship a fixer, so a project can lint with it and format with something else entirely. Because an external formatter takes precedence over the built-in one, a phpcs.xml handed phpcbf the job even on a project whose mago.toml says in as many words what it formats with, and every save reformatted the file to the PHPCS ruleset. A [formatter] table in the workspace mago.toml now settles it: the ruleset records what the project lints with, that table records what it formats with, and both are honoured.
  • An instanceof check on a value declared object|string narrows it to the class. A subject whose declared type names no class at all had the checked class added to its union rather than replacing it, so a route parameter typed object|string came out of if ($server instanceof Server) as object|string|Server and passing it to something expecting a Server was reported as string does not satisfy Server. Both spellings of the check were affected, including the guard form if (! $server instanceof Server || ! canManage($server)) where the right operand of || runs only once the negated check has failed. Every alternative in such a union is either subsumed by the checked class (object) or ruled out by the check succeeding (string), so the check's result is now the whole answer. Closes #359.
  • A custom Eloquent builder keeps the model it was built for. SiteCertificate::query()->whereKey($id)->firstOrFail() resolved to the base Model, or reported subject type 'TModel' could not be resolved, whenever the model routed its queries through a custom builder. PHP has no generics, so almost nobody writes @template/@extends on a builder subclass: class SiteCertificateBuilder extends Builder {} is the whole class, and the model was lost at whichever method on the chain returns it. The model the query was started from now travels through the builder to the end of the chain, so the result of firstOrFail(), first(), get(), and the rest completes, hovers, and is checked as the concrete model, whether the builder is declared with generics or without. A builder specialised this way also keeps everything the ordinary resolution gives it, including the query-builder methods it reaches through @mixin. Closes #362.
  • A custom Eloquent builder keeps its model however deep the builder hierarchy is. A project that gives its builders a shared base of their own (class AdminUserBuilder extends UserBuilder, where UserBuilder extends Builder) lost the model one level in, since neither of its own classes declares generics, and the query fell back to the base Model the way it used to before builders kept their model at all. The binding now reaches the generic ancestor wherever it sits in the chain, so a builder subclass at any depth resolves first(), get(), and the rest as the concrete model.
  • array_filter() reports the type the filter leaves behind. A callback that tests the value it is handed proves something about every entry that survives, but the result kept whatever element type went in, so array_filter($values, fn ($v) => $v !== null) still looked like it could hold null and returning it from a function declared int[] was reported as a type error. The surviving values are now narrowed the way the body of an if narrows the variable it guards, whether the test is written as an is_…() call, a comparison against null, an instanceof check, or a callable string such as 'is_int'. The keys were already narrowed this way in the modes that hand the callback a key, and both halves narrow together under ARRAY_FILTER_USE_BOTH. Closes #376.
  • A filtered list is no longer reported as a list. array_filter() keeps the key of every entry it keeps, so filtering a list leaves gaps in the numbering: array_filter([3, 4, 5], fn ($v) => $v > 3) starts at key 1 and has nothing at key 0. The result kept the container it was handed, so a filtered list still claimed the sequential keys it no longer has, and reading [0] off it looked safe. It now reports array<int, T>, while the functions that renumber (array_values(), array_merge()) go on answering list<T>. The element type is unaffected: whatever the filter proves about the entries that survive still travels with them.
  • A deprecation warning belongs to the variable it is written on. The deprecated-usage check typed its subject from a cache keyed by variable name and class, so two methods of the same class that reuse a parameter name shared one entry and whichever type was bound first won. A Laravel controller with a Request $request method above a PendingRequest $request method reported Illuminate\Http\Request::get is deprecated on the HTTP client call, where get() is the ordinary way to make a request, and renaming either parameter made the warning vanish. Every subject is now typed in the scope it is written in, so each method, closure, and instanceof branch gets its own answer, and a genuine deprecation is still reported no matter which order the methods appear in.
  • A generic argument left out takes the default its @template declares. @template TAsync of bool = false says that a use of the class without a generic argument means false, but the parameter was widened to its upper bound instead, so a conditional return keyed on it always picked the else branch. Laravel's HTTP client is where this shows up: PendingRequest declares synchronous mode as its default, so an ordinary Http::get() looked like it returned a promise rather than a Response, and json() along with the rest of the response API appeared to be missing on it. A plain request now resolves to Response whether it is made through PendingRequest, the client factory, or the Http facade, and async() still resolves to PromiseInterface through all three. Contributed by @shuvroroy (#377).
  • A conditional assertion narrows the value the call was written on. if (filled($search)) left a ?string nullable inside the branch, so passing it on to something that expects a string was reported as an error. Two things stood in the way, and Laravel's filled() and blank() hit both. An asserted type written as a union (!=null|'', which is how the pair is annotated) matched no type guard at all and narrowed nothing; ruling one out now rules out each of its members on its own. And a tag written in the equality form was being inverted into the branch it does not name, which typed every filled value as numeric|bool. Those tags promise something in one direction only, so they are now left out of the opposite branch, while the subtype form (!null) keeps narrowing both. Closes #375.
  • A route under a dynamic group prefix is no longer flagged as unknown. A route file that uses a variable in a group's ->name() call (as Filament does with Route::name($panelId . '.')) registers routes whose names cannot be enumerated statically. Any route() call whose name falls under the known static prefix of such a group was incorrectly reported as Unknown route. Those routes are now recognised as unjudgeable and left alone. Closes #373.
  • Every way of writing a dynamic route group is recognised. The exemption above only applied to a group nested inside another group with a literal name, so the two shapes a routes file most often writes, a top-level Route::name('filament.' . $panelId . '.')->group(…) and the array form Route::group(['as' => $dynamic], …), still had every route() call under them reported as unknown. Both are now recognised, and the exemption reaches exactly as far as the names the group could have registered: what its name spells out ahead of the first variable, whether the name is built by concatenation or by interpolation. Route names outside that prefix go on being checked.
  • Hover stands down at every declaration site, not just most of them. A class, interface, namespace, method, property, class constant, and enum case already answered nothing on their own declaration, since the signature and docblock are already on screen. A global function's own name, a top-level const, and a constructor-promoted parameter did not follow that rule: hovering function helper() repeated its signature, hovering const LIMIT = 5; repeated its value, and a promoted parameter (__construct(public string $sku)) hovered as a local variable rather than the property it actually declares. All three now stand down the same way the rest do.
  • A PHP 8.4 property hook body is read like any other body. Nothing inside a get or set hook was walked, so a method call, property access, or any other navigable expression written there was invisible: go-to-definition on get => $this->product->discountedMinor() answered null, and the identical call from a normal method resolved fine. A hook body now carries its own scope, so navigation, hover, and completion work inside one, $this is the class that declares the hook, a local assigned earlier in a block-bodied hook keeps its type, and a set hook's $value resolves whether it is spelled out (set(Price $value)) or left implicit. This covers a hook on a constructor-promoted property too, where the hooks sit in the parameter list rather than on a class member. Closes #342.
  • A parent hook call is no longer read as two missing members. parent::$label::get(), the way PHP 8.4 spells a call to the parent's hook, parses as a static method call on a static property access, and PHPantom took that literally: it reported both Member 'label' not found and Method 'get' not found on the parent class. Neither half is static, so the property now resolves as the instance property it is and the get/set accessor is left alone.
  • Hover no longer depends on indexing timing. A hover, go-to-definition, or any other request that arrived before the file's first parse published its symbol map answered null (or fell back to poorer resolution), so the same request could succeed in one session and fail in the next depending on how far background indexing had gotten. A request for a file with no symbol map now parses it on the spot from the content it already fetched, so the first answer matches every later one. Closes #343.
  • A raw Blade echo is read from its own opening brace. {!! $html !!} was only recognised when written as {{!! $html !!}}, a spelling Blade does not use, so the expression inside a real raw echo was masked as HTML: a variable declared in a <?php ?> block and displayed through {!! … !!} was reported unused, and completion, hover, and go-to-definition inside the echo answered nothing. The raw echo now compiles the way Blade compiles it, to a plain echo with no e() escape around it, and each echo form only closes at its own terminator, so !!} no longer ends an escaped {{ … }} early. Closes #370.
  • An echo opener with no terminator no longer swallows the rest of the template. A {{ with no }} anywhere after it, or a {!! with no !!} (<script>if (a) {!!b}</script> was enough) put the rest of the file into PHP mode: every later line was read as code instead of markup and the whole template stopped parsing. Such an opener is now closed at the end of its own line, so at most that line degrades and everything after it works as usual. An echo that is still being typed, or that spans lines with its terminator further down, stays open exactly as before, so completion inside a half-written echo keeps working.
  • A non-Laravel project's own config(), route(), view(), __(), or trans() function no longer hovers, navigates, or renames as a Laravel string key. Completion and diagnostics already stood down on a project with no Laravel dependency, but hover, go-to-definition, find-references, and rename did not, so a home-grown micro-framework (or WordPress's __()/_e() gettext helpers) that happened to declare one of those names got a fabricated "Route name" or "Config key" tooltip, a working-looking jump to a config/*.php file that has nothing to do with the call, and a rename that rewrote both. All four now stand down the same way completion and diagnostics do.
  • A workspace opened through a symlink or a path alias behaves the same as one opened directly. macOS reaches the same directory under both /var and /private/var, and any workspace opened through a symlink has two spellings of every path inside it. The vendor directory was recorded under one spelling while the walkers compared the other, so analyze read vendor/ as project code and reported errors from third-party packages, and find-references and rename searched it too. Blade had the mirror of the same problem: no template matched any view root, so the variables a template's view() callers pass were not typed inside it and hover and completion there answered nothing. Both spellings are now recorded, and a template is matched against its view root under either one. A composer install run while the editor is already open registers the new vendor/ as well, instead of waiting for a restart. Contributed by @shuvroroy.
  • Editing a Laravel service provider takes effect immediately. What a Laravel service provider registers was read once, when the project was first indexed, and never again. A container binding written afterwards did not resolve, hover, or navigate until the editor was restarted, and the same went for the view directories, translation directories, route files, config files, and Blade component namespaces a provider registers. Saving or editing a provider now re-reads it, and adding one to bootstrap/providers.php (or config/app.php) picks it up as well. A key that two providers bind still ends up with whichever of them the container itself would let win.
  • A Laravel request accessor written with named arguments keeps its key. $request->file(key: 'photos') and $request->header(default: 'x') read the named argument as whichever positional slot it happened to land in, so a keyed file() call resolved as though it named no field at all and a default-only header() call resolved as though its default text were the key. header(), query(), cookie(), input(), post(), and file() now bind a named argument to the parameter it actually names, including on an app's own FormRequest subclass, which never redeclares the accessor itself.
  • A conditional helper keeps a scalar return selected by its arguments. A helper whose PHPDoc selects between a class and a scalar could choose the scalar correctly and then replace it with the broader native return type when the call was used inline or passed into a generic function. In Laravel, that made url('/login') look like a URL generator even though it returns a string. url() and url(null) still resolve to the generator, while a non-null path now resolves to string consistently through direct chains, assignments, and template-bound calls. Contributed by @shuvroroy (#337).
  • Renaming a global constant no longer renames a class constant of the same name. Find References and rename on a global const BAR = 1; matched any class constant declaration named BAR too, since the check only compared short names. Renaming the global constant rewrote Holder::BAR's declaration in an unrelated class while leaving every Holder::BAR use site alone, producing a file that no longer compiled. A class constant's declaration is no longer treated as a candidate when searching for a global constant's references.
  • Find References on a global constant can exclude its declaration. A request with includeDeclaration: false still returned the const BAR = 1; line, unlike the same request against a function, method, or property, because a constant's declaration site carried no marker distinguishing it from a use of it. It is now excluded when asked, the same way a function's declaration already is.
  • An Eloquent factory's declared model wins over its name. Laravel checks a factory's protected $model property before trying its naming convention, but PHPantom ignored it, so a nonconventional factory, or one whose conventional name pointed at a different model, built Eloquent's base Model in the type engine. A ::class constant or string literal assigned to $model is now the associated model for inherited makeOne() / createOne(), count-dependent make() / create(), and dynamic relationship methods, through shared factory bases as well. A concrete @extends Factory<Model> binding remains authoritative; an unbound template yields to $model, and $model otherwise outranks the convention just as it does at runtime. A nullable value passed to count() now keeps Laravel's model-or-collection union instead of being forced to the collection branch. Contributed by @shuvroroy (#364).
  • A magic constant carries its own type. __LINE__, __FILE__, __DIR__, __CLASS__, and the rest resolved to nothing at all, so return __LINE__ + 3; in a function declared int was reported as returning int|float: the addition saw an operand it could not classify and fell back to the widest numeric result. Each one is now typed as what PHP gives it, an int for __LINE__ and a string for the others, so arithmetic on a line number stays an int and hover reads them the way it reads any other value. __CLASS__ keeps the class it names, the way Foo::class does, so a name captured from it still works where a class-string is wanted; inside a trait it is whichever class uses the trait, and outside a class-like it is the empty string it evaluates to.
  • A loop over an array that cannot be empty leaves the value it built behind. The $max = null; before a foreach is a sentinel for the first iteration to replace, and every path through the body replaced it, but the type after the loop still carried the null as though the body might not have run at all. Handing the result to anything that wanted an int was reported as handing it a null, and the guard the code was written with, if (!$qtys) { return 0; }, made no difference. An array that has been proven non-empty now says so in its type, whether by a truthiness guard, a non-empty-array annotation, an array shape with a required key, or a literal written with entries, and a loop over one runs its body at least once, so what the loop produced is what stands after it. A variable first assigned inside such a loop is likewise defined after it. An array that has not been proven non-empty is unaffected: the loop may not run, and the state before it survives as before.
  • A helper that bails out on its condition narrows the code after it. abort_if($user === null, 404) is the standard guard in a Laravel controller, and the line after it is only ever reached when the condition was false, but $user kept its nullable type from there on: its members were reported unknown, completion offered nothing, and passing it to anything that wanted a User was reported as passing a null. The four helpers that conditionally bail out (abort_if, abort_unless, throw_if, throw_unless) now prove what surviving them proves, in the polarity their name picks. They run the same pipeline an if condition does, so every guard form is honoured in both places: a null check, an instanceof, a type guard such as is_string(), and && chains of them. A condition passed as a named argument is found wherever it sits in the list, and a same-named function reached through a namespace is left alone.
  • A guard written inside a Blade {{ … }} echo narrows what it proves. {{ $countryName ? strtoupper($countryName) . ' ' : '' }} reported the argument as string|null, and {{ is_string($rule) ? $rule : $rule->getDescription() }} reported the whole union, even though each condition rules the other half out. Blade compiles every interpolation to an echo, and an echoed expression was the one place a ternary, an && chain, or a match (true) proved nothing, so a template's guards were ignored while the identical line written as an assignment or a return narrowed correctly. Plain PHP that echoes a guarded expression is fixed with it.
  • A write to a foreach value variable stays in the iteration that made it. A loop that ends with $step['key'] = $decoded fed that element type back into the next iteration's $step, which the loop had already rebound to a fresh element. The leaked type then survived the guards at the top of the body, so an isset(...) && is_string(...) check no longer proved the value a string and passing it on was reported as passing an array. Deleting the write, or the loop around it, made the report disappear. The value and key variables (and the names a destructuring foreach binds) are now reset to the iterated element type on every pass, so what the body wrote to them is discarded the way the loop discards it.
  • A match (true) arm's condition narrows inside the arm's result. $args = match (true) { $buy !== null && $pay !== null => [$buy, $pay], default => [] }; built an array of two nullable ints, so passing it on was reported against a type the arm's own condition rules out. The equivalent if narrowed correctly, and so did an instanceof arm, because match arms had their own small narrowing pass that knew about instanceof and nothing else. Every arm now runs the same condition pipeline an if body does, so a null check, a type guard, and an assertion helper all reach the arm's result, whether it names the value directly or builds an array out of it. Reaching a later arm proves the arms above it failed, so default => $x reads what a preceding $x === null arm ruled out. An arm listing several conditions still narrows only what all of them prove, since any one of them is enough to enter it. The same array-element gap affected a ternary branch and is fixed with it.
  • A ?-> chain compared to a value that cannot be null narrows its receiver. if ($parent?->getChild()->getInner() === $n->getInner()) can only be entered when $parent is not null: the chain would otherwise hold null, which is never identical to a value whose type excludes it. Returning $parent from inside the branch was reported as returning a nullable. The proof is now drawn from the comparison, in the else branch of the !== spelling too, and it follows the plain -> links written after the ?->, since one short-circuits the rest of the chain. A comparand that can itself be null still proves nothing.
  • An array built up with [] keeps the false half of what it stores. $files[] = realpath($path) recorded the element as string|bool rather than string|false, because the append boundary treated false as incidental precision and widened it the way it widens a literal string or int. Widening a boolean half invents the other one, so an assertNotFalse() on an element then stripped false from bool and left a true the array could never have held, which was reported as a type mismatch on the very next line. A boolean half now survives the append, so the failure-signal unions PHP's own functions return (realpath(), strpos(), file_get_contents()) narrow inside a loop exactly as they do on a plain variable.
  • A standalone @var cast above return is honoured. /** @var int */ written directly above return giveString(); (with no variable name) told the return-type check nothing: the returned expression was judged by its own inferred type as though the cast were not there, so a deliberate narrowing to the declared return type was reported as a mismatch. The annotation is now read the way PHPStan reads it, as an unconditional cast on the expression it precedes, whether that expression is a call, a variable, or an array literal. The named form (/** @var int $x */ above an assignment) already worked and is unaffected.
  • The type engine itself now knows that a class is an object and that a Traversable is iterable. Those facts, together with the equivalence between the ways one array type can be written (array<int, Cat>, list<Cat>, and Cat[] all describe the same values), used to be reachable only from the argument-type check, so everything else reasoned without them: to completion filtering and hover a Collection<User> was not an object and an ArrayIterator was not iterable, and an array whose element type only matched through a parent class did not match at all once the two sides were spelled at different arities. They live in the shared subtype check now, so every feature reads the same answer. This does tighten one case: a value that may be null no longer satisfies an object or iterable parameter, matching both what PHP does at runtime and how a nullable value has always been judged against every other parameter type.
  • A ternary inside a throw narrows its branches. throw new RuntimeException($model ? get_class($model) : '') still read $model as nullable inside the arm the check proves it is not, so whatever that arm passed on was reported against the wider type. The identical ternary in a return, an assignment, or a call argument narrowed correctly. A thrown value is walked like any other expression now.
  • A plain function's @return docblock is checked against its body. /** @return array<string, int> */ function bad(): array { return ['a' => 'x']; } went unreported, and so did every other return that satisfied the native hint but not the docblock behind it. The check read the array written in the signature and stopped there, so the type that says what the array actually holds was never compared to anything, while the same mistake passed to a parameter was reported as it should be. The docblock and the hint are now merged the way they are everywhere else, so a function is held to the type it documents rather than the weaker one it declares. Methods were never affected, which is what made the gap look like an array-shape problem rather than a function one.
  • A generic call keeps the alternatives its argument's return type declares. takesCarbon(passthrough(Carbon::create(2024))) was reported as fine even though Carbon::create() returns ?Carbon. The pass that works out what a @template binds to reads its argument as text, and that reading answers with the classes an expression can be, so the null arm was dropped and the template bound a plain Carbon. Every use of the substituted type then claimed the value could never be null: a missed report where the result is consumed, and an invented one where a parameter is checked against it. Writing the call to a variable first bound the nullable correctly, which is what made the two forms disagree. The alternatives a call's return type declares and the class walk cannot name, null and scalars alike, are now put back, so both forms bind the same thing. A call a check has already narrowed keeps the narrowed type rather than having the declared one restored over it.
  • A one-line function no longer inherits the previous function's @param. function g3(array $s): void { foreach ($s as $x) { doThing($x); } } opens and closes its body on the signature line, and the backward scan that looks for an enclosing docblock relied on watching brace depth rise and fall to spot where a sibling function ends; a body written entirely on one line never moves that depth, so the scan walked straight past g3 into its docblock and handed g4(Status $s) the @param array<Status> $s written for a different function entirely, reporting a type error against a type the parameter never had. The scan now also recognises a function keyword sitting at a depth it has already fully backed out of, which catches a body collapsed onto one line the same as one spread across several.
  • A guard on a call narrows the same call written again. if (currentUser()) { render(currentUser()); } is the shape a nullable accessor is written for, and it was reported as passing a ?User. A check on $holder->get() already carried to the next $holder->get(); a plain function call and a static call did not, because the scope had no entry for either to narrow. Both are now recorded under the call's own text like a method call is, so a repeated currentUser() or Session::current() inside the guard reads what the guard proved. A call that takes arguments is keyed with them, so checking one call still says nothing about a different one.
  • A guard on a ?-> chain's result narrows the receiver the chain ran against. $period = $agreement?->latestPeriod(); followed by if (!$period instanceof Period) { return; } proves $agreement was not null: a null receiver short-circuits the chain, and the guard would have returned. The proof was only drawn when the guard's own condition spelled the chain out, so storing the result in a variable first (which is how it is nearly always written) lost it and passing $agreement on was reported as passing a ?Agreement. The link between the value and the receivers it came from is now recorded where the chain is written, so any guard that rules out the value's null rules out theirs, whatever shape it takes. Writing to the receiver in between drops the link, since what the guard proves is about the value the chain actually ran against.
  • A ternary that repeats a property narrows it the way it narrows a variable. $alt = $article->alt ? $article->alt : $article->title; is the standard fallback idiom, and the then-arm still read $article->alt as the nullable it is declared to be, so the result was reported as nullable everywhere it was used. The same line written with a plain variable narrowed correctly, which is what made it look like a Blade problem: Blade compiles a component attribute into exactly this assignment. Both the proof and the read now key on the whole path rather than on the variable it starts from.
  • A @var docblock no longer cancels the rest of the statement it annotates. A /** @var Foo $x */ written directly above a statement is authoritative over the assignment it names, and the walker took that to mean the statement needed no further analysis at all: every narrowing pass for that line was skipped, so a ternary, an && chain, or an assertion helper on the same line proved nothing. Only the assignment is skipped now.
  • assertInstanceOf() on a mock leaves an intersection, not a choice. A mock really is both the interface it was built as and the class it stands in for, so assertInstanceOf(MethodNode::class, $mock) on a MockObject leaves a MethodNode&MockObject. It was recorded as MockObject|MethodNode instead, which satisfies neither half, so returning the value from a method declared to return the intersection was reported as a type error. A subject that is already an intersection now narrows within it as well, so (FunctionNode|MethodNode)&MockObject proven to be a MethodNode becomes MethodNode&MockObject rather than staying as it was.
  • An argument is not checked against a @template only it could have bound. Comparing an argument to a parameter type that was substituted from that same argument is circular, and PHPantom already stood the check down where the template had a single binding site. Laravel's travelTo names TDate in both $date and its optional $callback, and a call passing only the date still binds TDate from that one argument, but the second site's existence was enough to re-enable the check, so $this->travelTo(Carbon::create(2024)) was reported as expecting a Carbon and getting a ?Carbon, contradicting the callee's own @template TDate of …|null bound. What counts is now the binding sites the caller actually filled.
  • A @phpstan-assert-if-true promise about the receiver's own members is kept. PHPStan's Scope::isInTrait() is annotated @phpstan-assert-if-true !null $this->getTraitReflection(), and a tag whose subject is a member of the receiver rather than a parameter was ignored outright, so the paired getter still read as nullable inside the guard. Those now narrow the member as read through the variable the call was written on. A !null promise about a plain parameter was dropped for a related reason (the tag names no class, so the class-based narrowing had nothing to rule out) and is now applied as the matching is_*() guard would apply it. PHPStan leaves the identical isInClass() bare, so that pairing is supplied for it: extensions are written against it regardless.
  • A Stringable object passed to a string parameter is checked against the file's strict_types setting. PHP only converts a Stringable object to a string automatically outside declare(strict_types=1); under strict types the same call throws a TypeError. Every neighbouring type-juggling rule (int/float to string, numeric-string to int/float) already read the file's strict_types flag, but the Stringable rule was accepting the object either way, so a class relying on __toString() under strict types went unreported.
  • A fully-qualified call to an array builtin gets the same answer as an unqualified one. Writing \array_sum($counts) instead of array_sum($counts) disabled every one of the rules that read a builtin's return type off the array it was handed, because the rules were looked up under the bare name while the call arrived carrying its leading separator. \array_sum() on a list of integers went back to int|float, \array_pop() on a list of objects lost the object, and the same for the whole family. The separator is now stripped before the lookup, so the fully-qualified spelling that is house style in a good deal of library code behaves like the unqualified one.
  • array_chunk() reports the chunks it makes, not the values it groups. It was grouped with the builtins that rearrange an array's entries, all of which hand back the element type they were given, and it is the one that adds a level of nesting instead. foreach (array_chunk($ids, 500) as $chunk) gave $chunk a single ID rather than the batch of them, so passing it anywhere expecting an array was reported as a type error. Each chunk is now an array of the input's elements, renumbered from zero unless $preserve_keys asks for the original keys back.
  • max() and min() answer with the values they compare. Both were mixed for every call, which accepts anything: takesInt(max("a", "b")) and takesInt(max($strings)) both passed unchecked. A single iterable argument now reports one of its elements, and comparing several values reports one of those, deduplicated across the alternatives rather than nested, with true and false keeping their own type so a later ?: 0 or assert($m !== false) can still narrow the result.
  • An arithmetic expression passed as an argument now resolves the same everywhere, including as an array write key. $result[max($a - 1 - $b, 0)] = 'x' widened the array's key type to int|string, because the shared expression resolver had no answer for $a - 1 - $b at all outside of assignment tracking, so a call argument built from arithmetic fell back to nothing rather than to int. Arithmetic, comparison, bitwise, and spaceship operators are now resolved by the one pipeline every consumer shares, so the key stays int whether the surrounding code is a plain function or a class method.
  • array_filter() without a callback drops the values it filters out. The one-argument form keeps exactly the truthy entries, so an array<string, string|null> comes back with no nulls in it; it previously kept the declared type unchanged, which meant a value that had provably been filtered still had to be re-checked before use. The falsy half is now stripped from each union member by recursing into it rather than by asking whether the member as a whole is certainly falsy, so a ?bool sharing an array with other nullables no longer leaves them unnarrowed. Passing a callback leaves the type alone, since what a callback keeps says nothing about its type. This also sharpens every other consumer of the same narrowing (if ($x), $x ?:).
  • array_key_first(), array_key_last() and key() drop the null an empty array would give. The null only ever happens for an empty array, so code that has just proved the array is not empty (assert($weights !== []);) still could not return the key from a function declared to return one. An argument that promises entries, whether by a non-empty- type, a guard, or a literal with keys in it, no longer carries the null result.
  • array_map() reads a callback named by a string, and keeps the keys it was given. The element type came from the callback's return type, which was only ever read from a closure written out at the call site, so array_map('intval', $ids) fell back to a bare array where array_map(fn (string $s): int => (int) $s, $ids) resolved. The named function's own return type is now what the call reports. Alongside that, a single-array call keeps the input's keys rather than renumbering them into a list, which is what PHP does; passing several arrays still produces a list.
  • A ?? argument decides a builtin's return type. str_replace('Error: ', '', $error ?? '') reports one type for a string subject and another for an array one, and the coalesce came back with no type at all, so the call kept both. It resolved correctly for the same value assigned to a variable first, which made this a gap in reading the argument rather than in the rule. A coalesce now reports the union of its left operand without null and its right, wherever a call's arguments are read.
  • A union of a class and a scalar narrows on both sides of an instanceof. Decimal|float resolves to one class and one scalar, and the type engine kept the pair on a single entry that only named the class. An instanceof check ruling that class out therefore had nothing left to point at and dropped the whole union, so the guarded branch kept the type the check had just disproved: the else of if ($value instanceof Decimal) still read Decimal|float and passing $value to number_format() was reported as a type error, as was the body of if (!$imgix instanceof Image). Ruling a class out now subtracts just that class and leaves the rest of the union standing, so both spellings of the check narrow to the half that survives it. A union of two classes always narrowed correctly and is unchanged.
  • A guard that repairs a value is not undone where the branches rejoin. Catching a bad value and replacing it on the spot is one of the most common shapes in PHP: if (!$value) { $value = 'fallback'; } on a string|false, or if (!is_array($status)) { $status = [$status]; } on a value that may or may not already be a list. After the if, every path holds the good value, but the merge put the original union back and the line below was reported for a false or a bare item that cannot reach it. Two things caused it. The path where the check did not hold only ruled out null, where the same guard written as if (!$value) { return; } correctly ruled out everything falsy; and an array literal built inside the branch read its elements from the parameter's declaration rather than from the branch, so [$status] wrapped the un-narrowed value. Both now agree with the guard, so the merged type is the repaired one.
  • A guard reaches the reads derived from the value it narrowed. Narrowing a variable and then reading something derived from it went back to the declaration instead of to the guard: an array-dimension fetch ($violationMessage['args'] inside if (is_array($violationMessage))), an argument to one of the array functions whose result follows its input (array_slice($cached, 0, $limit) inside if ($cached !== null)), and the same read after a @phpstan-assert guard such as PHPUnit's assertNotNull(). Each of those resolution paths consulted the backward @param/@var scan first, and that scan describes a variable where it is annotated, not where it is used, so the arm the guard had just ruled out came back and the read was reported as a type error. All of them now read the guarded scope first and fall back to the annotation only when the scope has nothing to say.
  • A typed class constant keeps the value it was given. PHP 8.3 lets a class constant declare a type (private const int DEFAULT_OPTIONS = JSON_HEX_TAG | JSON_THROW_ON_ERROR;), and PHPantom took that declaration as the whole answer, so everything the initialiser said was thrown away the moment a type was written in front of it. Only the declared type reached the code that reads a constant's value, which is what decides a flag argument, a match subject, and a comparison against a constant. The everyday symptom was a json_encode($value, self::DEFAULT_OPTIONS) reported as string|false when the mask it is handed sets JSON_THROW_ON_ERROR, a failure that throws rather than returns. The initialiser is now read the same way an untyped constant's is, and the declared type is what stands when the value cannot be worked out. A declaration that says more than its initialiser does keeps its own answer, so a constant typed as an enum still resolves to that enum rather than to the case it happens to hold.
  • A member read off a class constant resolves against the value the constant holds, not the class that declares it. self::TYPED->value, self::UNTYPED->value, and Matrix::TYPED->value were all reported as Property 'value' not found on class 'Matrix' for public const Kind TYPED = Kind::A; and public const UNTYPED = Kind::A;, since the class constant's subject resolved to the class it was declared on rather than to the enum case it held. Reading a member off Class::CONST now resolves against what the constant's value or declared type actually is, so an enum case stashed in a constant still exposes ->value whether the constant is typed, untyped, or read through self::, static::, or the class name directly.
  • An array<T> or T[] keeps a key type a callback proves. These name a value type and say nothing about their keys, which the type engine read as "integer keys" because that is the useful default for iterating one. It is only a default, though, and a callback that filters on the key contradicted it: array_filter($data, fn ($k) => is_string($k), ARRAY_FILTER_USE_KEY) had nothing left to keep and handed back the type it was given, so passing the result to a parameter wanting array<string, string> was reported as a mismatch. The narrowing now starts from every key PHP permits, so the shorthand gets the same answer the spelled-out array<string|int, string> already did, and the result carries that key type through an array union (+) with other string-keyed arrays. A list<T> does promise integer keys and is unchanged.
  • An array index the code computes types the element it writes. $mapping[$line + 1] = $value; resolved the index through a narrower path than an assignment's right-hand side, and that path has no answer for arithmetic. The index came back unknown and the whole array widened to array<int|string, …>, so returning it from a function declared array<int, …> was reported as a type error. Computed indices now resolve the same way any other expression does, which covers arithmetic on a loop counter, an increment, and anything else the shared resolver already handles.
  • A call handed straight to a key- or value-reading builtin binds from its return type. array_keys($this->templates()) answered list<array-key> where array_keys($templates) on a local holding the same call answered list<string>. The forward walker records a placeholder for a call it has not resolved yet, and that placeholder was read as mixed, wide enough to look like an answer, so the argument never reached the resolver that knows the method's declared return type. An unresolved placeholder is now treated as unknown rather than as mixed.
  • isset() proves a chained array key present through a variable index. if (!isset($state['files'][$path]['violations'])) { return; } left the optional violations key nullable on the line below, because the chain was only tracked when every index was written as a literal. A variable index is now part of what the check is recorded against, so the read that follows sees the key the guard proved is there. It stays a statement about that element: writing to the index variable afterwards drops the proof, since the chain then addresses a different one.
  • An && or || guard narrows the operand beside it wherever the check is written. The right-hand side of && runs only when the left-hand side held, and the right-hand side of || only when it did not, so a check on the left says something about the value the right reads. PHPantom only drew that conclusion when the whole check was an if, while, or for condition or a return value. Written anywhere else the guard proved nothing, and the operand beside it was read at the type the guard had just ruled out. That covers most of the places these are written: assigned to a variable ($ok = is_string($v) && strlen($v) > 0;), passed as an argument, put in an array, used as a ternary's condition, or nested inside a larger check. A guard now narrows the operands that follow it in every position, so the false type errors those forms produced are gone. It stays a statement about the operands: nothing after the check the guard sits in is affected, exactly as before.
  • An autoloader's closure parameter is a string. The class name PHP hands an autoloader is the only thing it is ever called with, but the stub for spl_autoload_register() promises no more than a callable, so spl_autoload_register(function ($class) { … }) left $class with no type at all. Every string builtin applied to it then answered for an argument of any type: str_replace('App\\', '', $class) came back as array|string, and building the file path out of it was reported as a type error in the one place the value is guaranteed to be a string. The callback is now typed the way PHP calls it. Registering the default autoloader by passing nothing, a function name, or null is unaffected.
  • ReflectionClass::newInstanceArgs() returns an instance rather than a maybe-instance. It builds the same object newInstance() does, and throws when it cannot, but the stubs still carry the nullable return type PHP 5 gave it. A method returning $reflection->newInstanceArgs([$arg]) was therefore reported for returning Node|null where it declares Node, and the same call written as newInstance($arg) was fine. Both now resolve to the instance type, so neither asks for a null check against a null that cannot arrive.
  • A truthy check rules out the values that can only be false. if ($value) and !empty($value) narrowed away null and false and stopped there, so anything else PHP treats as false survived the branch that proves it did not. The everyday way in is a default that is not of the value's own type: $markets = $this->option('markets') ?? []; on a console command leaves string|array{}, and the guard below it, written precisely so the rest of the body has a string, left the empty array in place. Passing it to explode() was then reported as a type error on code that cannot reach the line with an array. Every always-falsy value is now dropped by a truthy test: the empty array, the empty string, '0', 0, and 0.0. What is only sometimes false is kept, so a plain string or int still spans both, and an array shape with a field it must have is truthy and stays. The same rule already decided whether a @phpstan-assert guard held, and both now read truthiness the same way, which also corrected '0' there: it is a falsy string in PHP, not a truthy one.
  • An empty array literal is checked against the shape keys it is missing. takesConfig([]) against @param array{host: string, port: int} $config was accepted silently, while takesConfig(['host' => 'localhost']) correctly reported the missing port key. An array literal is only compared key-for-key when every key it holds is known statically, and [] was mistakenly excluded from that check for holding none, so the emptiest possible mismatch was the one case that went unreported. [] now reports every required key as missing, the same as any other literal that leaves one out.
  • A file with box-drawing characters keeps its diagnostics. Working out an untyped method's return type from its body starts by locating the method in the file it is declared in, and that position was measured against the file as it was last read. When the two disagreed, which a file being edited does routinely, the position could land in the middle of a multi-byte character and the whole file's analysis stopped there, taking every diagnostic in it with it. Any file holding a character outside ASCII could hit it, and a comment drawn with was the usual way in. The position is now read for what it is worth, and a stale one gives up on that one inference instead of on the file.
  • A second namespace block in a file is checked like the first one. PHP lets one file declare several namespaces, and everything after the second namespace line belongs to that namespace. PHPantom read only the first one, so every name written further down was looked for in the wrong place and found nothing: argument types went unchecked because the function or method being called was never located, and a function defined elsewhere in the second block's namespace was reported as not found. Names are now resolved against the block they are written in, so the second block and everything after it is analysed the way a single-namespace file is.
  • A static property remembers what was written to it and what was checked about it. self::$x, static::$x, and Foo::$x were read straight from their declaration wherever they appeared, so nothing a method did to one reached the next line that read it. The lazy-initialisation idiom, which is what a nullable static property is usually there for, therefore reported the null its own guard rules out: if (self::$repo === null) { self::$repo = new Repo(); } return self::$repo; was still ?Repo at the return. A write and a check are both recorded now, and a read consults them, so the property carries the type the body proved rather than the one it was declared with. What the body has not proved is unchanged: a write on only one branch of an if, a later write that puts the null back, and a property another method might have changed all keep the declared type.
  • An assignment inside a try survives a catch that rethrows. A catch that throws or returns never reaches the code after the try, but the state it left was merged in anyway, so it put back the type a variable had before the try body assigned it. try { $h = new Holder(); } catch (RuntimeException) { throw new LogicException(…); } left $h nullable afterwards even though the only path that reaches there assigns it. A catch that falls through still contributes its state, as it should.
  • An && chain inside a match arm narrows its own operands. match ($kind) { 1 => $this->a && $this->b && $this->same($this->a), … } checked the last operand against the type $this->a had before the chain started, so a null the chain's own first operand rules out was reported. The same chain written as a return statement narrowed correctly. A match arm and a ternary branch now refine their operands the way any other expression position does.
  • Identity against an enum case rules out null. $land === Land::Be && $this->takes($land) reported ?Land at the call: === only holds between two values of the same type, so a subject compared to an enum case cannot be null for the rest of the chain. Every class constant carries the same proof, and it is the constant's own type that decides what the comparison rules out, so a const NONE = null; still proves nothing.
  • A loop condition narrows its own operands. The && narrowing an if condition performs was not applied in a do/while or for condition, so do { $node = $this->parseOptional(); } while ($node && $this->addChild($list, $node)); reported the null the condition's first operand rules out. Both now narrow as if and while already did.
  • A namespaced constant is found however it is written. A const declared inside a namespace was indexed under its last segment alone, so the only reference that could find it was one that named it without the namespace. Config\GRADES, \App\Config\GRADES, and a name reached through a use const import all resolved to nothing: hover showed no value, Ctrl+Click went nowhere, and everything the constant's value proves was lost, so a strict in_array($grade, Config\GRADES, true) gate stopped narrowing and the code after it was reported against the type the gate had just ruled out. Constants are now indexed by their fully-qualified name and a reference is resolved against the file it is written in, the way a class or function name is, including PHP's fallback from an unqualified name to the global constant of that name. A define() still names exactly the string it was given, wherever the call sits, which is what PHP does.
  • A nullable value is checked the same way whichever way it is written. ?string and string|null describe the same value, but only the second was ever checked: passing a ?string to a string parameter, returning one from a string function, and assigning one into a string property all passed silently, while the same code with the union spelling was reported. Which of the two a value carries is an accident of how it was produced, so the same argument was reported or not depending on whether its type came from a declaration or from a branch merge, a ?? chain or an optional array-shape key. Both spellings are now read as the union they stand for, so a null the code has not ruled out is reported wherever it is passed, and a nullable value satisfies a parameter that lists null among its own types.
  • A property keeps what a check proved about it across a method call. if (!$payment->id) { throw … } followed by $order = $payment->load(); threw away everything the check had established about $payment's properties, so the very next line reported the null the guard exists to rule out. A call can still change what a recorded call through the same receiver answers ($stmt->fetch() after $stmt->execute()), and that is still dropped, but the properties read through it are kept.
  • A ternary inside new Foo(…) narrows in its branches. new Wrapper($h->name ? strtoupper($h->name) : '') was checked with the type the value had before the condition, so the branch that only runs when the value is there was reported against its nullable type. Arguments to new are now narrowed like the arguments to any other call.
  • array_filter keeps what its callback proves about the keys. In the two modes that hand the callback the key (ARRAY_FILTER_USE_KEY and ARRAY_FILTER_USE_BOTH) the result was reported with the key type it went in with, so array_filter($data, fn ($k) => is_string($k), ARRAY_FILTER_USE_KEY) still claimed integer keys the call exists to remove, and passing it on to a parameter declared array<string, …> was reported as a mismatch. The keys that survive are now read off the callback the same way an if (is_string($k)) body is read, whether it is written inline or named ('is_string'), and a callback that admits every key it could receive leaves the type alone.
  • An element write refines what the array already holds. A write into a variable whose keys were already tracked was thrown away rather than recorded: $row[] = $pen on a array{name: string} left the shape exactly as it was, and so did $row[$key] = 1, so the value that had just been written was not there to read back. Both are now applied. An append takes the next free integer key beside the keys already tracked, an append one level down extends what that key holds instead of leaving it at the value it was initialised with, and a write through a key only known at runtime widens the shape to the keys and values it and the existing entries describe together, since a runtime key may land on any of them. The reverse mistake is gone too: writing a literal key into an array declared by key and value type (array<string, int>) rebuilt it as a shape holding that one key, discarding every other key it was known to hold, and appending to a string-keyed array called the result a list. Both now keep the array's key and value types and fold the written pair into them.
  • An array shape answers the list and non-empty-array promises from its own entries. A shape was compared against those types by name alone, so array{} satisfied a non-empty-array parameter and array{name: string} satisfied a list, while a real list of values written as array{string, int} did not satisfy list. A shape is now non-empty when it names a key that is always there, and a list when its keys run 0, 1, 2, … in order with any optional entry at the end.
  • A "\x8b" escape no longer takes the server down with it. A hex or octal escape in a double-quoted string decodes to a raw byte, and a string built that way (strpos($output, "\x8b"), a gzip magic number, a binary delimiter) is text no character encoding can express. PHPantom read those literals as though they were ordinary source text, which is undefined behaviour: on the command line the analysis died part-way through, in the editor the server exited, and in a release build it silently read whatever happened to follow in memory. Every place a literal's value is read now checks it first, so a literal like this is simply one PHPantom has nothing to say about. The rest of the file is analysed as before.
  • An array literal records what sits at each position. [$violation, $file, $line] collapsed to a list of everything it held, so every way of reading one slot back out gave the same RuleViolation|string|int: destructuring the row with [$violation, $file, $line] = $row, indexing it with $row[1], or pulling it out of the collection it was pushed into. Rows written this way now record what sits at each position, so a slot read is the one value that slot holds, and passing it on no longer reports a type mismatch against everything the row happened to contain. This already worked for a literal nested inside another; it now holds wherever the literal is written. A literal that spreads another array, or one long enough that its arity is beside the point, still describes itself as a list.
  • An array literal argument is still checked against the list or array type it is compared to. Recording a literal's exact positions made its type more specific than the list or array it is being compared against, and the comparison treated that as automatic compatibility regardless of what the literal actually held: takesIntList(['x']) against @param list<int> $values went unreported, and so did passing [1, 'b', 3] where @param array<int>|array<string> promises one or the other, not a mix. Each entry is now measured against the value type the parameter declares, so a value that does not fit is reported the same as it was before literals became this precise.
  • A literal keyed on something PHP works out at runtime is described by its keys and values. [$name => 1] was reported as array{mixed: 1}, a shape with a field named after the key's type rather than after anything in the code, and a read off it found neither the key that was written nor the one that was asked for. Such a literal is now an array<K, V> built from the key and value types it does hold, and a key that is not written as a plain string or integer keeps the type it resolves to, so [Event::class => $handler] satisfies a parameter declared array<class-string, …>. PHP coerces null, true and false before using them as keys, so those now land on the '', 1 and 0 entries they index at runtime.
  • (object) [] is a stdClass. Casting an empty array to an object produced an object{}, a shape with no properties, which nothing else in the engine produces and which was rejected by every parameter declared stdClass. It is now the stdClass PHP builds. A cast of a non-empty array still keeps the properties it names.
  • Reading a key a shape marks optional carries the null a missing offset yields. A @var array{file: string, type?: string} says the type entry may not be there, but reading it produced the same string a required key does, so a value that is absent at runtime was passed on as though it could not be. The read now says it may be missing, which is what makes the ?? and the isset() around it mean something. A key the shape requires is unchanged.
  • !empty($row['name']) proves the key is there. isset($row['name']) narrowed the entry it named, but the !empty() spelling of the same check only ever narrowed a plain variable, so a proof about an array entry or a property path was dropped. Both spellings now record what they prove, in an expression position (a ternary, a match (true) arm) as much as in an if body, so the guarded read no longer reports the falsy half the guard ruled out.
  • An append below a key refines the array it writes into. $grouped[$id][] = $row told us nothing: a [] was only ever read at the end of a plain variable, so a push through a key chain left the variable as whatever it was initialised to, usually the empty array{}, and the rows collected under each key offered no completion when they were read back. A trailing [] is now tracked like any other element write. The level it appends to becomes a list of what was pushed, and the levels above it are created as the write walks through them, so that loop ends with array<int, list<Row>> and $byLetter['all'][] = $word keeps its shape entry as array{all: list<string>}. A push onto a value that is not an array is left alone, so $collection[] = $item on an ArrayAccess object stays that object rather than turning into a list.
  • An array union keeps the keys of both sides. $config += ['slot' => $default] and $merged = $defaults + $overrides collapsed to a bare array, losing every key the operands held. PHP's array union keeps what the left side has and adds only the keys the right side contributes, and the merged type now says the same, so the result stays a shape whose keys complete and hover, in both spellings. A key the left side only optionally holds takes the right side's value as an alternative, since that is what it falls back to. Where one side is not a tracked shape the key and value types are carried across rather than dropped, and two positional arrays union index by index as PHP does.
  • list<T> and array<int, T> are recognised as the same type. Two array types were only ever lined up when both were written with the same number of type arguments, so a list<T> never satisfied an array<int, T>, an array<V> never satisfied an array<array-key, V>, and a list of array shapes was rejected by a parameter declared array<int, array<string, mixed>>. Each spelling now contributes the key and value type it implies before the two are compared: a list keys on int, a one-argument array on array-key, and a one-argument iterable promises nothing about its keys at all. A list on the receiving end still demands sequential keys, so a plain array does not pass for one.
  • An Eloquent model factory keeps its model type through a shared factory base. Projects often put common factory helpers on an unannotated BaseFactory, leaving each concrete factory to Laravel's naming convention. PHPantom applied that convention to the intermediate base instead of the concrete factory, found no corresponding model, and reduced every inherited generic return to Eloquent's base Model; a factory reached correctly from Draft::factory() could therefore still make what looked like the wrong class. Convention inference now stays anchored to the concrete factory across the whole inheritance chain, so its inherited build methods return the associated model. Contributed by @shuvroroy (#356).
  • A doubly negated guard narrows like the bare one. if (!(!$user)) left $user as possibly null inside the body, while if ($user), if (!($user === null)), and every other spelling of the same test narrowed. The outer ! was a shape no guard recognised, so nothing was proved at all. A pair of ! now cancels before the condition is read, and it cancels per conjunct, so wrapping one operand of an && costs the chain nothing. Blade's @unless compiles to if (!…), which makes @unless (!$user) exactly this shape, so the body of one narrows now too.
  • A remembered check does not outlive the state it was made about. if ($stmt->fetch('id') !== false) proves something about the row the statement is on, and a repeated $stmt->fetch('id') inside the branch reads that proof rather than the declared array|false. But an intervening $stmt->execute() moves to another row, and the proof stood anyway, so the sentinel the guard exists to rule out was reported as impossible on a value that could be exactly that. A call on a receiver now drops what was remembered through that receiver, whether that is a repeated call, a property path, or an element. The receiver keeps its own type, a call on some other object leaves it alone, and a callee declared @pure, @phpstan-pure, or @psalm-pure promises it changed nothing, so the proof survives it.
  • iterable is a type guard. is_iterable($x) narrowed nothing, and neither did the @phpstan-assert iterable tag PHPUnit's assertIsIterable() carries: iterable names no class, so the instanceof route could not carry it, and the guard route had no kind for it the way it has one for array, string, and callable. Both now narrow. A union keeps the members foreach can actually walk, which is an array in any of its spellings plus an object whose interfaces reach Traversable, so a generator and a collection survive the guard while a plain object leaves with the scalars. The failing branch is the inverse proof, and a mixed that passes the check reads as iterable rather than staying mixed.
  • An assertion about $this narrows it, even where a docblock rebinds the closure. A Pest test closure is bound to whatever pest()->extends(…) names, and no expression in the test file says what that is, so the suites write assert($this instanceof AppTestCase); as the closure's first line to spell it out. The @param-closure-this tag on Pest's test() had the last word on what $this was, so the assertion was read and then thrown away and every helper the real base class provides was reported as an unknown member. The tag says what the closure is bound to, which is a starting point like any declared type, so a proof inside the body now refines it and the subclass's members resolve. The tag still wins over the $this a closure merely captured from the method around it, which is the case it exists for.
  • A constant defined from other constants holds their value. const FLAGS = JSON_THROW_ON_ERROR; and const COMBO = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR; were read only as far as their type, a plain int, so everything built on one lost the value behind it: json_encode($data, self::FLAGS) was still reported as possibly false even though the flag rules that out, and a match or a comparison against such a constant could not be decided either. The initialiser is now folded to the value PHP computes for it, through as many constants as it names and across the bitwise operators (|, &, ^, <<, >>, ~) a constant expression may use, and a constant that is simply an alias of another one (const NS = Base::NS;) holds what that one holds. A mask assigned to a variable first ($flags = JSON_UNESCAPED_SLASHES | self::FLAGS;) keeps its value too, so the call it is passed to reads the same bits as the call that spells the flags out. A constant defined in terms of itself has no value to hold and is left alone rather than chased in circles.
  • A variable holding a global constant resolves to it while diagnostics run. $mode = PHP_ROUND_HALF_UP; resolved fine on hover but came back as nothing at all when the same file was checked, because the constant lookup was dropped on the way into that path. Anything the variable was then handed to had no type to check against, so a genuine mismatch went unreported and an argument-dependent return type could not be decided.
  • An unquoted key inside an interpolated string is read as a key. "$data[code]" is how PHP spells a string key inside a string: the quotes are not just optional there, they are a syntax error. The key was resolved against the current namespace as though it named a class, so unknown_class was reported once for every interpolated key, the key was coloured as a class name, and an import that happened to share the name was counted as used. The key now resolves as the string it is, while a key written outside a string ($data[MY_KEY]) stays the constant it is. The same went for the name in the older "${data}" and "${data['code']}" spellings, which was reported as an unknown class rather than read as the variable; it now resolves as one, so hover, go-to-definition, find-references, and rename reach $data through it. Contributed by @HelgeSverre (#352).
  • A @see tag that carries prose or a naming suggestion is no longer reported as a missing symbol. @see legally holds a URI, a sentence of prose, or a name that deliberately does not exist, alongside the FQSEN form it is best known for. PHPantom read the first word of every tag body as a symbol name, so @see the vendor manual "Widget Format v2" on page 3 reported a missing function the and @see ShortWidget as a potential shorter name reported a missing class, one diagnostic per descriptive tag. Nothing distinguishes prose from an FQSEN followed by a description, which is why other phpdoc tooling reads the tag leniently, so a @see target that resolves to nothing is now left alone. A target that does resolve is unchanged: hover, go-to-definition, find-references, and rename still reach the class, function, or member it names. PHPUnit's @covers and @uses are written the same way but name a code unit that has to exist, and those are still reported. Contributed by @HelgeSverre (#353).
  • A phar whose stub ends without a line break is read rather than skipped. PHP writes __HALT_COMPILER(); ?> followed by a line break and its own reader will skip one if it is there, but it does not require one, and archives built by hand or by older tooling put the file index straight after the marker. PHPantom insisted on the line break, so it gave up on those archives entirely and every class inside one stayed unknown. The line break is now optional, and where the first byte of the index could be mistaken for one the archive is re-read from the correct position instead of being rejected.
  • A never branch of a conditional return type asserts the argument that would have selected it. throw_unless($dispatcher, …) and its family (throw_if, abort_unless, abort_if, and anything else written the same way) declare their effect in the return type, ($condition is false ? never : …), rather than with an assertion tag. A never branch is a branch the call cannot return through, so an argument value that selects it cannot have got past the call, but the argument was left exactly as it was. Guards written this way were invisible: the value they had just proved present was still reported as possibly null on the very next line. What the branch rules out is now subtracted from the argument for the rest of the scope, the same subtraction if (!$x) { throw …; } already gets, and it applies to a property or an array element as readily as to a variable.
  • An assertion that names a pseudo-type narrows its argument. A @phpstan-assert resource $actual tag (PHPUnit's assertIsResource()) or a @psalm-assert null $actual (assertNull()) was read and then dropped, because a type that names no class had nowhere to go: the class-based path could not carry resource or null, and only the scalar types had a route to the check that is_resource() and is_null() already use. Both now take that route, so a handle stays a handle for the rest of the test and a value asserted null is not still reported as possibly a string.
  • A strict in_array() against a list of values narrows the needle to them. if (!in_array($user->getEmail(), self::APPROVED, true)) { abort(403); } proves on the far side of the gate that the email is one of the approved addresses, so it cannot be null. Only a needle whose type named a class was narrowed before, which left every scalar gate (an allow-list of strings, a set of status codes, a list of locales) proving nothing at all. The needle now keeps only the values the haystack could hold, whether the haystack is a list<string> parameter, an array written out at the call, or a class or global constant. A constant list of literals also carries its values now rather than flattening to a bare array, so foreach (self::APPROVED as $address) and self::APPROVED[0] describe what the constant actually holds.
  • A checked call is remembered by the next occurrence of the same call. if (mb_strpos($slug, $marker) !== false) { mb_substr($slug, 0, mb_strpos($slug, $marker)); } reported the inner mb_strpos() as int|false all over again, because a check only ever narrowed a variable and a repeated call was resolved from scratch. Writing the call out twice is how the guard idiom reads, so this hit $this->option('from') ? Carbon::parse($this->option('from')) : null and every strpos/array_search/getenv guard written without a temporary variable. A call is now remembered under its own written form, arguments included, so the second occurrence reads what the check proved. The proof lasts as long as the values it was made about: writing to anything the call reads drops it, as does leaving the branch or going round the loop again, and a call that hands back something different each time (fgets(), array_shift(), time(), rand()) is not remembered at all.
  • A Laravel request accessor answers for the call it was written as. header(), query(), cookie(), post(), input() and file() each declare one type covering every way of calling them, because a PHP signature cannot say that the answer depends on the arguments. So $request->header('User-Agent', '') came back string|array|null even though a header is never an array and the '' is what a missing one produces, and $request->query() with no key came back the same way even though it can only be the whole query array. Each call is now read the way Laravel reads it: no key at all is the whole bag, a key is the item, and a default that is not null is what rules the missing-key branch out. file('photo') is the upload it names, with the request's own validation rules telling a photos[] field's list of uploads from a single one.
  • A function called through an imported namespace is no longer reported as undefined. use Core\Ip; followed by Ip\isIpAllowed($address) is how PHP reaches a function in an imported namespace, and the call was flagged as an unknown function even though Ctrl+Click on that very name opened the declaration. The two disagreed because only one of them said where the name was written: go-to-definition asked with the position in hand and got PHP's own answer, while the diagnostic asked without it and was left guessing from the file's imports and its namespace, neither of which can resolve a name of that shape. The check now asks the same way everything else does, so it agrees with navigation, and it follows a call into a file that declares several namespace blocks rather than assuming the whole file lives in the first one. Contributed by @petrovo-as.
  • A use function or use const import is part of the symbol it names. Only class imports were indexed, so an import line was a dead end: Ctrl+Click on it went nowhere, hover said nothing, it was missing from the function's list of references, and a rename rewrote every call while leaving the import pointing at a name that no longer existed. Both kinds are now indexed as the symbol they actually name. A global const FOO = 1; also gained a declaration of its own. Previously only the value it was assigned was ever looked at, so find-references and rename could not be started from one at all. Contributed by @petrovo-as.
  • Renaming a function or constant keeps its imports and aliases intact. A name written as use function Foo\bar; is the same symbol as the bar() that calls it, but not the same text, and the rename treated it as if it were: it replaced the whole line's name and left use function baz;, dropping the namespace. An aliased import fared worse. use function Foo\bar as quux; became use function baz as quux; and every quux() in the file was rewritten to baz(), turning code that compiled into code that does not. The alias is a local name and stays valid once the function is renamed. Each mention is now rewritten as what it is: a qualified import keeps its namespace and moves only the name at the end, a plain call takes the new name, and an alias and the calls that use it are left alone. Function names are matched case-insensitively and constant names are not, the way PHP matches them. Contributed by @petrovo-as.
  • Go-to-implementation answers for the interfaces your dependencies ship. Asking for the implementations of a method declared in a Composer package (HttpKernelInterface::handle() in a Symfony application, say) came back empty once the workspace index was ready, because results were narrowed to the project's own classes and Symfony's HttpKernel was dropped along with everything else under vendor/. That narrowing now applies only when the symbol you asked about belongs to the project, so an interface shipped by a package is answered from the classes that ship beside it. Two narrower exclusions are gone as well: an abstract class whose method has a body implements that method, though a method re-declared abstract still does not, and a concrete class that inherits the method unchanged resolves to the ancestor that declares it instead of being skipped for not overriding it. Requests on PHP's own interfaces, Countable and the like, still answer from the project alone, since their implementations span the entire dependency tree, which costs far more to collect than the answer is worth.
  • A by-reference out-parameter is no longer null after the call that fills it. preg_match($pattern, $subject, $matches) writes $matches whenever it is passed, but its signature spells the parameter ?array &$matches = null, and that null was carried straight through to the caller. So the array a successful match had just produced was still reported as possibly null, and every $matches[1] and $matches['name'] read inside the guard came back as string|null and had to be re-checked against a null that could not happen. A by-reference parameter that defaults to null is an out-parameter: the null says the caller may leave the argument off, not that the callee may leave it unset. It is now dropped from what the variable holds afterwards, across preg_match, preg_match_all, parse_str, exec and the rest of the standard library's out-parameters as well as your own functions. A nullable by-reference parameter with no default keeps its null, since nothing there says the callee writes it.
  • Array builtins answer in terms of the array they were handed. array_keys(), array_values(), array_search(), array_key_first(), array_key_last() and key() all describe the caller's own key or value type, but PHP signatures cannot say that, so the stubs spell out int[]|string[] and string|int|false and every call carried a branch it could never take: array_keys() on a string-keyed array reported a list of ints as a possibility, and array_search() reported an int key. Each now reports the key or value type of the array it was given, and array_values() reports the list it really builds rather than the keyed array it was handed. An array whose keys are genuinely unknown still gets what PHP guarantees, an array-key, instead of an invented answer.
  • A scalar-element array keeps its element type through the array builtins. array_filter(), array_slice(), array_merge(), array_unique(), array_reverse(), array_pop(), current() and the rest preserved the element type of a list<User> but dropped a list<string> back to a bare array, because the check that asked whether the input had a known element type was answering a different question: whether that element was an object. Since most PHP arrays hold strings and ints, this was the common case rather than the corner one. The element type now survives whatever it is, so array_pop() on a list<string> is a string, not mixed.
  • array_sum() and array_product() over an array of ints report an int. Both are declared int|float for PHP's numeric promotion, but an array that only holds ints cannot produce a float, and the float half then had to be ruled out by hand at every use.
  • A @template named in several alternatives of a union @param binds from the one the argument matches. An annotation such as @param Collection<TKey, TValue>|array<TKey, TValue> $items offers a binding site per alternative, and which applies is decided by the argument rather than by the order they were written in. Only the first was tried, so passing an array to that parameter bound nothing and the return came back with its templates erased. Each alternative is now tried in turn, and an alternative that does not name the template is skipped rather than binding the whole argument type to it.
  • Each branch of an if contributes what it ends with, and only that. Where branches rejoin, PHPantom mixed up what a branch proved with what it left behind. A reassignment made under a check the value already satisfied, as in if ($v instanceof AbstractNode) { $v = $v->getNode(); }, kept reporting the old type beside the new one, because the branch that was skipped described a run that cannot happen and was merged in anyway. A branch that only narrowed leaked the other way: after if ($r instanceof Verbose) { … } the value kept the branch's narrower type instead of returning to what it was declared as. And a proof about a value nothing had a type for, such as a property read off a stdClass, escaped the branch it was written in entirely, so the check appeared to have typed the variable for the rest of the function. All three now follow the same rule: the type after the if is the union of what its branches end with.
  • A break carries its state out of the loop. The value a variable held when a break left the loop was lost, so only the paths that ran to the end of the body were counted. $a = 'x'; do { if (rand(0,1)) { break; } $a = 1; } while (…); reported 1 rather than 'x'|1, and a break out of an inner loop lost the assignment it had just made. Every break is now an exit of the loop it names, and joins the code after it alongside the ordinary fall-through. A break inside a switch reads the same way, so an arm that leaves early contributes what it leaves with.
  • An inline @var describes the assignment it is written above, not every later read. /** @var null|list<…> $cached */ $cached = Cache::get(…); followed by an ordinary if ($cached !== null) still reported the nullable type inside the guard, and a later reassignment of the same variable was overridden by the annotation too. The annotation now seeds the assignment it documents and then flows like any other type, so guards narrow it and reassignments replace it. A variable the annotation is the only source for, as in a Blade template, reads exactly as before.
  • Laravel's __() and trans() report the line they resolve to. Laravel declares the translation helpers as string|array|null, because a key may name a whole group and the keyless form hands its own null back, so every {{ __('checkout.title') }} echo, __() fed to an HtmlString, and assertSee(__('key')) mismatched against the string it really is. The key at the call site settles it now: a key naming a single line is a string, a key naming a group is the array of lines beneath it, and __() with no key at all is the null it returns. A key built at runtime keeps both remaining branches and passes wherever either would, since no call that names a key returns null. Lang::get() reads the same way.
  • A @property tag's name is coloured as a property. The member name in a @property tag was highlighted as a method, while the same name was coloured as a property everywhere it was used, because a tag-declared member is not among the class's own members and fell through to the method default. Both tags are now classified by the tag that declares them.
  • A successful instanceof check narrows the value down to the class, not out to a wider union. assert($obj instanceof Configuration) on a value declared object|null reported object|null|Configuration: the check added the class beside what was already there instead of ruling out everything the class does not cover. The if (!$obj instanceof Configuration) { throw … } guard proves exactly the same thing and now goes through the same code, so its fall-through narrows identically rather than drifting on its own.
  • A check on a ?-> chain narrows the receivers it ran through. if ($image?->file_id !== null) said nothing about $image, so the body still had to treat it as possibly null even though a null receiver is precisely what makes the check fail. Every receiver along the chain is non-null inside the branch now, whether the proof comes from a comparison against null, a truthy test, an identity check against a value that is not null, or the fall-through of a guard that returns. A branch the check does not prove, such as the else, keeps the nullable type it had.
  • A type guard says what a value is even when nothing else does. $version = $row->version on a plain stdClass yields no type at all, and a following assert(is_string($version)) was skipped for want of a type to narrow, throwing away the one statement that described the value. The guard now establishes the type outright, in an assert() and past an if (!is_string($v)) { return; } guard alike.
  • A null check on an array element refines that element. isset($m[0]), $m[0] !== null and assert(isset($m[0])) left a following $m[0] reading string|null on an array<int, string|null>, because only a constant array shape had a slot to record the proof in. The element the check named now carries it, without claiming anything about the array's other keys.
  • A conditional return type keyed on an argument's type picks a branch. Order::find(7) reported Collection<int, Order>|Order|null, every branch of the conditional at once, because a condition like ($id is array<mixed>|Arrayable ? … : …) was only ever settled by an argument written as a literal. The argument's resolved type now decides it, through the class hierarchy where the condition names a class or interface, so one id finds one model and a list of ids finds the collection. The same reading covers is null, which followed whether an argument was written at all rather than what it holds, so a nullable value no longer commits to the non-null branch it may not take, and is not null takes the branch its negation names. A condition that genuinely cannot be decided still reports both branches. Long chains of these decide as a whole, so spatie's Data::collect() reports the collection its arguments select instead of a union of all nineteen, and a value the branch names as a @template (tap($order, …)) is filled in from the call rather than reported as a bare TValue.
  • A resolved value no longer names the same class twice. A union carrying both a class and a generic over it (Collection<int, Order>) reported the collection twice, once bare and once parameterised, and a model with a custom collection listed that alongside the generic it stands for. The parameterised spelling now belongs to the class it names.
  • More builtins report the shape their arguments actually select. A standard-library function that returns one of several things depending on how it was called can only be declared as the union of all of them, so every call carried branches it could never take. pathinfo($path, PATHINFO_FILENAME) reported the whole component array beside the string it really returns, print_r($v, true) kept a boolean, hrtime(true) and microtime(true) kept the array and string forms of their default, getenv('HOME') kept the whole-environment array, mb_convert_encoding() on a string kept an array branch, abs() on an int kept a float, and SimpleXMLElement::asXML() reported a bool that could not be split into the string it serialises to. Each now resolves to the branch its arguments select, including through a named constant and through the parameter's declared default when the argument is left out. A call whose deciding argument cannot be pinned down keeps every branch, which is all it can promise.
  • A conditional return type keyed on a value reads named constants. Only a literal written at the call site settled a condition like ($flags is 15 ? … : …); a constant, or a local holding one, decided nothing and the call silently took the fallback branch. The argument's resolved value now settles it either way, and a condition naming true or false is told apart from a plain is bool so each boolean literal picks its own branch. When the value genuinely cannot be determined, both branches are reported rather than committing to one.
  • A fully-qualified global constant resolves. \PHP_EOL and friends, written that way inside a namespace to skip the fallback lookup, were searched for under the leading separator and never found, so hover showed nothing and any type that depended on the constant's value fell back.
  • A @property tag beats an inherited property nobody can reach. PHP only calls __get() when no accessible property of that name exists, so a protected declaration up the chain is never what the read yields. PHPantom reported its type anyway: an Eloquent model documenting @property string $connection still resolved $model->connection through Model's own \UnitEnum|string|null, and the same happened for $table and $keyType, which models shadow routinely. The tag now describes the read it was written for. A property the class declares itself is a different matter and keeps its own type, since it is in scope everywhere the tag is, and so does an accessible inherited one.
  • An accumulator that starts as [] counts in whole numbers. $totals[$k] = ($totals[$k] ?? 0) + $n is the standard way to tally by key, and the first pass read the empty array as an unknown value rather than a miss, so the sum came out int|float and failed every array<string, int> it was declared as. An offset read on [] now yields null, which is what PHP produces and what the ?? was written to catch. The empty array also stops trailing along beside the array a later write produced: a variable seeded with [] and appended to in a loop, or captured by reference and filled in by a closure, reports the array it ends up holding instead of that alternative plus the empty one it started from, so reading an element out of it no longer carries a null from the empty half.
  • A ternary's arms see what its condition proved. is_string($req) ? $req : 'today' handed both arms the raw string|array|null, so a value the condition had just established was still reported against every string the ternary fed. Each arm is now resolved under its own polarity of the condition, using the same narrowing an if/else body gets, and it happens wherever the ternary is written rather than only in some positions: assignment, argument, and return all behave the same. That covers the whole family of conditions rather than a list of recognised shapes, so a type guard, a null or falsy check, instanceof, a member-existence proof, and anything added to narrowing later all reach the arms. A nested ternary's else arm carries the outer conditions' inverse narrowing as well as its own, and ?: still yields the truthy half of its subject.
  • A negated compound guard narrows by every conjunct. if (!is_string($payload) || $payload === '') { return; } is the standard way to reject everything a function cannot handle, and the code after it was left with the un-narrowed union: the guard proved nothing. Falling through an || means every operand was false, so each operand's own inverse now applies, whatever kind of check it is. Previously only instanceof and member-existence checks were read one operand at a time, and the rest were matched against the whole || expression, which never matched. Every exit form works (return, throw, continue, abort()), so does the else branch, and so do chains of more than two conjuncts. is_resource() joins the is_* family it was missing from, and !== '' / !== [] now refine to non-empty-string / non-empty-array rather than only removing a literal that was never in the union.
  • An array written under a string key stays keyed by string. Every non-literal string key widened to int|string, on the grounds that a numeric string becomes an int key at runtime. Only a literal decimal-integer string does, so a function building array<string, string> reported array<int|string, string> and failed its own declared return type, including after an explicit (string) cast, a backed enum's ->value, and ReflectionProperty::getName(). A key expression now keeps its own domain: string stays string, int stays int, and the int conversion applies to literal decimal keys alone. ++$i and $i++ resolve as well, so a counter used as a write key no longer falls back to array-key.
  • An assignment in an elseif condition narrows what it wrote. } elseif ($token = $request->bearerToken()) { puts $token in scope and proves it truthy, and the leading if form read it that way, but the elseif form applied the narrowing before the assignment had happened, so the body saw the raw nullable type. The two now run in the same order, for both brace and elseif: syntax.
  • A variable seeded with false can be checked for. $time = false; … $time = strtotime($raw); … if ($time) { date($f, $time); } is how PHP code has always written "not parsed yet", and PHPantom widened that first false to bool the moment it was assigned. bool includes true, so the truthiness check had nothing to subtract and the guarded body still saw bool|int, which was then reported against date()'s ?int parameter. A written true or false now keeps its own type the way every other literal does, so the join is int|false and the check clears the false half. A value that genuinely is bool narrows to true inside a truthy branch as well, so the common $found = false; foreach … { $found = true; break; } if ($found) pattern reports what the branch proved. A type written back into source still widens to bool: an inferred return type suggests : bool rather than the true that would need PHP 8.2 and would reject a subclass returning the other half.
  • An override's own return type has the last word over the docblock it inherits. An implementation without a docblock of its own inherits the interface's @return, which is usually what you want, but the implementation's native declaration is a promise the wider union does not get to override. An interface declaring @return array<string, mixed>|list<mixed>|string with an implementation declaring : array reported the string half as a possible result, and that half then failed every array parameter the value was passed to. The inherited union is now restricted to what the override's own declaration allows. Only alternatives whose kind is unmistakable take part, so a class name, object, callable, or iterable on either side rules nothing out.
  • An arrow function keeps its parameters in the type it produces. fn (BrandView $view) => $this->attrs($view) was inferred as Closure(): array<…>, with the arity and the parameter type dropped, so passing it where a Closure(BrandView): array<…> was declared was reported as a mismatch. Closure and arrow function literals now carry their declared parameters, with an untyped parameter contributing mixed, which any expected parameter type still satisfies.
  • ctype_digit() and define() accept what PHP accepts. The bundled stubs type ctype_digit() and the rest of the ctype_* family as taking a string, and define()'s $value with the scalar-or-array union it had before PHP 7. php-src says mixed for both, so ctype_digit($count) and define('STDERR', fopen('php://stderr', 'wb')) were reported as type errors on code that runs fine. Both are widened back. Passing an int to a ctype_* function is a deprecation rather than a type error, and belongs in a deprecation diagnostic if it is worth surfacing at all.
  • A parenthesised union inside an intersection is spelled back with its parentheses. & binds tighter than |, so printing (FunctionNode|MethodNode)&MockObject without them produced FunctionNode|MethodNode&MockObject, which reads as FunctionNode|(MethodNode&MockObject) and drops the intersection from the first branch. Hover and diagnostic messages now keep the parentheses, so the type reads back as the one that was written.
  • A union no longer names the same type twice. Diagnostics printed types such as string|App\Entity\User|string and null|string|array|null, which is cosmetic but appears in most multi-branch messages and makes the real disagreement harder to spot. Repeated alternatives are dropped wherever a union is built, and a branch join that produced both halves of a boolean settles on bool rather than listing true|false.
  • A guard that exits by calling a never method ends the branch whatever the call is written on. A guard body whose only statement is a call to a method declared never cannot fall through, so the code after the if sees the narrowed type, and PHPantom read it that way only when the call was written on a variable. $app->abort(422) ended the branch; the same call written app()->abort(422), (new Application())->abort(422), or $this->responder->abort(422) did not, and the guarded value kept the union it was supposed to have shed. The receiver is now typed through the same resolution every other expression goes through, so all of those forms end the branch, and the value that survives a Laravel if (!$file instanceof UploadedFile) { app()->abort(422); } guard is the UploadedFile the guard proved it to be.
  • An instanceof check rules out the array half of a union, not just the other class. A method whose return type mixes a class with an array of it, such as Laravel's Request::file() returning UploadedFile|array<UploadedFile>|null, resolves to a single value that names the class and carries the whole union alongside it. Narrowing only ever reached the class half, so the array stayed put: after a correct if (!$file instanceof UploadedFile) { throw … } guard, or inside a plain if ($file instanceof UploadedFile) { … }, passing the value to a parameter typed UploadedFile was still reported as passing UploadedFile|array<UploadedFile>|null. A check that concludes what the value is now drops the alternatives it has ruled out, including a null the guard had already proven impossible. A check that only rules something out is unaffected, so the array half still survives a negated instanceof inside the branch it guards.
  • An argument written as an array element, a global constant, or a simple operator expression is no longer read as nothing. str_replace(), preg_replace(), and any @template binding all decide part of their answer from the argument's own source text, and several ordinary ways of writing that argument left them with nothing to go on: str_replace('a', 'b', $data['message']) where $data is array{message: string}, preg_replace('/-.*/', '', PHP_VERSION), and preg_replace('/\s+/', ' ', $body ?: '') all kept the full undecided union of both replace branches, even though assigning the same expression to a variable first resolved it correctly. An array-shape or generic element now reads its own value type instead of only the class-backed results the general resolver reported; a bare identifier that names a global constant is read through the same constant lookup hover already uses; and concatenation (always string) and the elvis operator (the union of both sides) are read directly rather than being mistaken for a bare variable name.
  • A fully-qualified type-guard call narrows like its unqualified spelling. \is_array($x), the style PHP-CS-Fixer's native_function_invocation rule enforces, kept the leading backslash in the function name PHPantom compared against is_array, is_string, and the rest, so the comparison never matched and the guard was silently ignored in both branches. The same held for \is_a(), \class_exists() and its siblings, and \property_exists()/\method_exists(). All four narrowing checks now strip a leading backslash before matching, so a project that fully qualifies its builtin calls narrows exactly as one that does not.
  • Formatting a .blade.php file is a no-op. textDocument/formatting handed Blade markup straight to the Pint/php-cs-fixer/phpcbf/mago pipeline with no check on the file's extension, so running "Format Document" on a template sent directives, {{ }} echoes, and component tags through a PHP formatter, which most likely errored out or produced nonsense edits. Formatting now returns no edits for a .blade.php file (matched the same way completion and hover already recognise Blade, so it also covers a file opened with a blade language ID that lacks the extension) until real Blade-aware formatting lands.
  • An array literal whose keys are out of order no longer passes for a list. list{string, string} and list<string> promise the keys run 0, 1, 2, … in that order, which is what array_is_list() answers true for, and [1 => 'x', 0 => 'y'] does not hold. PHPantom accepted it silently: a list{…} was read as the array{…} it prints as, and a shape's keys were never compared against the order a list requires. The promise is part of the type now, so a parameter written as a list is reported for a literal whose keys are reversed, gapped, or named, and the message says the order is the complaint rather than leaving two near-identical type expressions to be diffed by eye. Hover also spells such a parameter back the way it was written instead of widening it to an array{…}. Only a literal written out at the call site is judged, since an array built up across assignments lists the keys we saw in the order we saw them, which says nothing about the order the value's keys are really in.
  • An indexed collection keeps its element type after a check on it. if (!$category->translations[0]) { continue; } is the ordinary way to make sure a relation's first entry is there before reading it, and the check itself was what lost the type: the entry was recorded under a name built from the whole expression, and that name was then read back as if translations[0] were a property the class declared. Nothing declares it, so a model that answers any property name at all answered this one with mixed, and because a check's conclusion outranks anything else, every later read of the same expression was judged against that mixed. $category->translations[0]->name was reported as unverifiable, both inside the guard and everywhere after it, and one guarded expression was enough to spoil the identical unguarded expression further down the file. The index is now read as an index, so the entry keeps the collection's element type, however many property hops it took to reach the collection and whether the guard is written on its own or as one link of a longer || chain.
  • assert() proves whatever the same condition proves inside an if. assert($handle !== false) is how a T|false return from fopen(), pg_connect() or finfo_open() is checked before use, and PHPantom went on reading the value as resource|false for the rest of the scope, reporting every use of it as if the assertion were not there. Only assert($x instanceof Foo) and the docblock-declared assertions were ever recognised, so a plain comparison narrowed nothing. The condition an assert() carries now goes through the same pipeline an if or while condition does, so every guard form is honoured in both places: a !== null or !== false sentinel check, an is_string() or is_array() type guard, an && chain that proves several things at once, a check on a property or an array entry rather than a plain variable, and the fully-qualified \assert() a namespaced file writes.
  • A union of objects is narrowed by a check on the property that tells them apart. Two classes that differ in how they type one property are a discriminated union, and the ordinary way to tell them apart is to check that property: is_string($b->v) picks out the member whose $v is a string, and $r->tag === 'ok' picks out the member whose tag is pinned to that value. Only the property itself was narrowed, so the subject stayed the whole union and everything downstream of the check was judged against both members at once, reporting a return $b in the guarded branch as incompatible with the type that branch had just proven. The subject is narrowed now, in the guarded branch, in the else branch, and after a guard clause that returns or throws. A member is only ever dropped when its own declaration rules the check out, so a property typed wide enough to pass either way, one the class does not declare at all, and one shared unchanged across the union all leave the subject exactly as it was.
  • A guard clause on a property proves the same thing it proves about a local. if ($this->handle === false) { return; } is how a T|false property is checked before use, and PHPantom went on reading the property as string|false for the rest of the method, reporting every use of it as if the guard were not there. The same check on a local variable narrowed correctly, so the guard's shape was never the problem: a property path was simply not recorded as the subject the check ruled a value out of. It is now, so a property survives the guard narrowed, whether the guard ends its branch with a return, a throw, or a continue. !$this->handle and empty($this->handle) name a property the same way, which they previously did not name at all. The path may be as deep as it needs to be, and a later write to it replaces what the guard proved rather than outliving it.
  • An array literal keeps the values it was written with. [1, 1.5, '123'] says exactly what it holds, but the values were widened to int|float|string the moment they were stored, so reading an entry back could not be proven to be anything the individual values were. Handing $values[$key] to a parameter or return type of numeric was reported, even though every entry of the array is numeric on its own, and the same held for a literal key: $values[2] read as string rather than as the numeric '123' written at that position. The values a literal names now survive into its type, so a read off it, a foreach over it, and an inferred @return all see them, and a key that is only known at runtime resolves to the set of entries the array actually has. Widening now happens where the array is changed instead: a push or a keyed write says the array is being built up rather than written out, so the value arriving there stands in for however many more follow. An alternative already covered by a broader sibling is folded away, so a list mixing a plain string with two string literals stays list<string>, and a literal naming more distinct values than a set of alternatives is worth reasoning about falls back to the base types.
  • A constant table constrains a plain signature too. @param key-of<ID_TABLE> says a parameter takes one of the table's keys, and @return value-of<ID_TABLE> says the result is one of its values. PHPantom only looked behind the constant's name while working out a call's @template bindings, which a function that declares no @template never does, so both tags widened to whatever a key or a value could be in general: acceptsKey('nope') went unreported, and a return that can only ever be int|string was read as mixed. The constant is now read wherever a declared parameter or return type names one, so an untemplated function or method is held to the table's own keys and hands back the table's own values. The declaration reads the same way from inside the body: the parameter holds the keys the table has, so hover names them and passing one on is judged against them, and a @return naming the table is held to what the table holds, so returning a key the table does not have is reported where it is written. The Class::TABLE and self::TABLE spellings read the same way, and a constant that cannot be reached, or whose value is not an array literal, still widens rather than being guessed at.
  • A lookup into a constant table reads as the entry its key names. A constant holding an array literal is the ordinary way to write a table of settings, and a function that reads one out of it can say so: @template T of key-of<ID_TABLE> with @return ID_TABLE[T] names the value under whichever key the caller passed. PHPantom read neither tag, because the docblock only ever sees the constant's name and nothing looked behind it, so the declaration's own int|string stood for every call and takesInt(lookUp('immutable')) was reported for passing a string. The constant's initializer is now read where a type operator asks for it, so each call resolves to its own entry: hover names it, argument checks judge against it, and a key the table does not hold is still rejected. The Class::TABLE spelling reads the same way, and a constant whose value is not an array literal is left alone rather than guessed at.
  • An omitted argument reads a constant table under the key its own default names. @template T of key-of<ID_TABLE> with @return ID_TABLE[T] resolves to a single entry at every call site that writes the key out, but a parameter carrying its own default (function lookUp(string $type = 'immutable')) bound nothing when the caller left the argument off, so lookUp() fell back to the whole table's value union and takesInt(lookUp()) was reported for passing a string the call can never return. A default value is as known at the declaration site as an argument is at the call site, so it now binds the template the same way: lookUp() resolves exactly as lookUp('immutable') does, for a method as much as for a function.
  • A for loop's update clause carries its type into the next iteration. for ($node = $head; $node !== null; $node = $node->next) is how a linked list is walked by hand, and the reassignment in the update clause counted for nothing. The clause was read far enough to hover and navigate the variables in it, but the type it produced was never fed back into the loop, so the body saw whatever the initialiser bound on the first trip through on every trip, and the variable kept that type after the loop as well, even where the update clause was the only thing that could have changed it. The clause now runs where PHP runs it, after the body and before the condition is checked again: the body sees the type the update produces alongside the one the initialiser bound, and a walk that ends because it ran out of nodes leaves the cursor holding null rather than the node it started from. The initialisers stay the one-time seed they are, so a variable the update clause retypes is no longer reset to its starting type.
  • Arithmetic on a refined int no longer widens to int|float. int + int is int, and that held for the bare spelling, but strlen(), count(), and most of the standard library's counting functions are declared with a refinement like int<0,max> rather than plain int, and accumulating one of those ($length += strlen($text);) read as an unrecognised operand and fell back to the conservative int|float, reported several lines away at the function's return rather than at the addition that caused it. Every int refinement (positive-int, non-negative-int, int<min,max>, and the rest) is now classified as int for arithmetic, and the same holds for float's own refinements.
  • A foreach key is typed from what is being iterated. foreach ($xs as $i => $x) over a list<int>, an int[], or an array shape left $i as int|string, the entire domain a PHP array key can occupy, even where the subject can only ever produce one half of it. The key now comes from the subject: a list and a T[] bind an int, and an array shape binds whichever of int and string its own keys are. So an argument check on the key says something, and filling a second array through it ($rows[$i] = …) yields array<int, …> instead of widening the key to int|string. A subject that genuinely says nothing about its keys, a bare array or an untyped parameter, still leaves the key int|string, since that is all either one licenses.
  • A check written beside the assignment it guards narrows the variable. while (($line = fgets($handle)) !== false) is the compact form every stream read loop is written in, and the check ruled nothing out: the body saw the whole string|false the assignment produced, so passing the line to anything that takes a string was reported on the line the condition exists to protect. The assignment now lands in the loop's scope before the condition narrows it, and a check reads through the parentheses to the variable the assignment wrote, so the sentinel is gone for the body. The bare truthy form (while ($parent = $parent->getParent())), the null sentinel, and the same shapes written as an if all follow, the negated guard if (!$row = $query->first()) { throw … } among them.
  • A read loop keeps the narrowing its condition established. while ($line !== false) { useString($line); $line = readLine(); } is how every fgets(), fgetcsv(), and readdir() loop is written, and the read at the top of the body was judged against string|false, on the line the condition exists to protect. The reassignment at the bottom was the cause: to find what a variable holds on the second and later trips through a loop, PHPantom walks the body once ignoring the position it was asked about, and when that walk was the only one, the answer it left behind was the type at the end of the body rather than at the position asked for. A walk that honours the position now always follows, so the read sees what the loop entry established, and a read written below the reassignment still sees the reassigned type. foreach, for, and do/while share the walk and are fixed with it.
  • An array filled in over several branches reads as one array. Building a lookup up a branch at a time, $rows = []; and then a $rows[$item->id] = … under each of a handful of ifs, is how half the report-building code in a procedural codebase is written, and PHPantom described the result by listing every stage it passed through: array|array<int, A>|array<int, A|B>|array<int, A|B|C>, one cumulative snapshot per branch, each one overlapping the last. Hovering the variable said nothing legible, and a function honest enough to declare array<int, A|B|C> was reported as returning something incompatible with its own signature. The branches now merge into the single array they describe, so the type is the one the code builds. Two arrays that hold genuinely different things, assigned in sibling branches rather than written into one array, are still a union, which is what they are. An empty [] is also read as the empty array it is rather than as an array of unknown contents, so it no longer trails along beside whatever gets written into it.
  • A type PHP has no declaration for is reported where it is written. function takesResource(resource $value) reads as a real type hint and is not one: PHP has no resource declaration, so it warns that the name "is not a supported builtin type and will be interpreted as a class name" and then looks for a class called resource, which does not exist either. PHPantom accepted it silently, because resource is part of the vocabulary a docblock may draw on and nothing checked where the name was written. A native hint naming something PHP does not support is now reported the same way any unresolvable class name is, which covers the legacy aliases (integer, boolean, double, real) and the PHPStan-only spellings (number, scalar, list) as well as resource. Every type PHP does support is untouched, in any casing, since those are reserved keywords, and a docblock may keep using the whole vocabulary, which is where it means something.
  • assert() on an array element narrows that element. assert($items[0] instanceof Foo) is how a test or a defensive read states what a slot holds, and reading the same slot afterwards ignored it: the assert only ever narrowed a plain variable, so $items[0] kept whatever the array's element type said. The element is now narrowed under the same key the equivalent if ($items[0] instanceof Foo) uses, for a numeric index and a string key alike, and only for the element the assert names: a sibling key keeps its declared type.
  • !== false narrows inside the branch it guards. fopen(), finfo_open(), strpos(), and every other function that reports failure with false are guarded by writing if ($handle !== false), and PHPantom read the body of that if as though the check were not there: the value stayed T|false and passing it to anything that takes a T was reported, on the line the check exists to protect. The check now rules false out for the branch, the way !== null already ruled out null, and it holds for a while condition as well as an if. Only false is ruled out, so a T|false|null value keeps its null and is still reported: null !== false is true.
  • A docblock can refine one member of a native union. /** @return false|string */ written over a bool|string return type says the only boolean it ever hands back is false, which is what makes the idiomatic !== false check worth writing. The refinement was discarded: a native union was refinable only when one of its members was a class or a broad container, so an all-scalar union kept its declared spelling and callers saw bool|string no matter what the docblock said. Each member is now checked against the docblock member that narrows it, the same check a lone native bool already passed, so boolfalse, intpositive-int, and stringnon-empty-string all reach the type inside a union too. A docblock that describes something the native union does not mention is still ignored, since there the native hint is the more trustworthy of the two.
  • ?: no longer keeps the value it exists to replace. The whole point of $body = $response->getContent() ?: ''; is that the empty string stands in when the call hands back something falsy, so $body is a string. PHPantom kept every falsy member of the condition in the result anyway, so the variable read as string|false and passing it to anything that takes a string was reported as a type error, on the exact line written to prevent it. The short form now contributes only what its condition can be when truthy, so the T|false and ?T idioms resolve to T, and a condition that can only ever be falsy contributes nothing rather than a branch that cannot be reached. A full ternary is untouched, since it names its then branch explicitly rather than reusing the condition as the value.
  • Builtins whose failure branch nobody checks stop being reported. tempnam() is declared string|false, and so are a couple of hundred other builtins whose false only turns up when something has gone wrong the caller could not act on locally. Real code passes the result straight on, and PHPantom reported every one of those as passing string|false where a string was wanted, so ordinary filesystem, cache, DOM and date code produced a steady stream of errors with nothing behind them. The branch is no longer enforced on the builtins PHPStan itself exempts, which is the same list read from its function map. The type is otherwise untouched: hover still reads string|false, and a caller that does check the branch still narrows through it. The leniency is tied to those specific builtins rather than to |false at large, so a false that carries an answer, as strpos()'s does, is still reported.
  • A check written on a method call reaches the argument that repeats it. if ($this->service() instanceof MockInterface) proves what the call hands back, and the natural way to use that is to write the call again inside the branch. Completion and hover read the narrowed type there, but an argument did not: $this->mockMethod($this->service(), 'annul') was measured against the method's declared return type and reported as passing the concrete class where the interface was expected, inside the very branch that proved otherwise. The call now reads the check wherever it appears, so the argument is judged on what the branch established, and the value stays both its declared class and the checked interface rather than trading one for the other. A call that takes arguments is still left alone, since an argument is a hint that the call does something rather than just handing back state.
  • An instanceof on a nested property keeps the property's declared class. if ($this->service instanceof MockInterface) proves the value is both its declared class and the interface at once, and that is what a mock is. Adding a level, as in $this->holder->service, dropped the declared class and left the interface alone, so passing the value on to something that takes the concrete class was reported as a type error inside the very branch that proved it. Only the leading variable of a path is ever assigned in scope, so the object half of a deeper path resolved to nothing and the check had no declared type to intersect with. Each segment is now resolved against the one before it, so a path of any depth narrows the way the one-level form already did.
  • A literal that mixes positional and keyed entries keeps its positional ones. ['first', 'b' => 1] names a shape with two entries, 0 and b, but a literal with at least one string key discarded every entry written without one and kept only the keyed entries, so $row[0] resolved to nothing. The unkeyed entries now stay in the shape at the position they were written, keyed by the sequential index PHP assigns them, so $row[0] resolves to the type of 'first' alongside $row['b'].
  • An array literal that leaves out a required shape key is reported. A parameter documented as @param array{host: string, port: int} $config names the keys the callee is going to read, but takesConfig(['host' => 'localhost']) passed without a word, and the missing key surfaced later as an undefined-index warning somewhere inside the function. A required key the literal does not hold is now reported at the call site, and the message names the keys that are missing rather than leaving two long shape spellings to be diffed by eye. Key order is irrelevant, an extra key is harmless, a key the shape marks optional is by definition not required, and an entry written without a key counts as the index PHP gives it, so ['a', 'b'] still satisfies array{0: string, 1: string}. The check needs the array written out at the call site, because that is the only place all of its keys are visible: a shape built up over several statements, or one holding a key that is not a plain literal, records the keys PHPantom watched go in rather than everything the array holds, so a key it does not mention is unproven rather than absent and nothing is reported.
  • A closure that returns the wrong thing for a callable(...) parameter is reported. A parameter documented as @param callable(int): string $callback says what the callee will do with the result, but passing static fn (int $v): int => $v was accepted without a word: any two callable-ish types were treated as compatible, so the whole signature went unchecked. The return type is now compared, and the message names the two halves that disagree (return type int does not satisfy string) rather than leaving it to be read out of two type spellings. A closure's return counts whether it was declared or resolved from the body, and a closure that offers neither, along with a bare Closure or a callable named by a string, still says nothing to contradict and is left alone. Parameter types are not compared yet. This also corrected array_filter's callback, which PHPantom typed as returning bool: PHP tests the result for truthiness, so the everyday array_filter($items, fn ($i) => preg_match($re, $i)) is not a mistake.
  • A class name written with an escaped backslash is recognized. 'App\\Model', "App\Model", and 'App\Model' are three source spellings of the same runtime value, but a string literal's content was taken from its raw source text rather than decoded per PHP's quote rules, so an escaped backslash, or any other escape sequence, left the value one character too long to match anything in the project. class-string<T>, interface-string, and Larastan's model-property<Model> literal checks now decode a string literal's escapes before resolving it, so a class name copied out of a double-quoted string, or written with a doubled backslash by habit, resolves the same as its plain spelling.
  • A partially-compatible union argument is now reported. acceptsLevel(gives()), where gives() returns 1|99 and acceptsLevel() declares @param 1|10 $level, was accepted silently: the check asked whether any member of the argument union satisfied the parameter, so a satisfying member (1) hid one that didn't (99). It now asks whether every member satisfies the parameter, the same rule already applied everywhere else a union is checked against a type, and the message names the specific member that doesn't (99 does not satisfy 1|10). The same laxness applied to any union source, so an int|string value passed where int is declared is reported too, matching the TypeError PHP raises for the string case under strict_types. Getting there without new false positives meant closing three narrowing gaps the old laxness had been quietly covering for: an elseif's own condition no longer sees a reassignment made in the preceding if-branch as though it had already run; if ($x === false) { throw …; } now narrows false out of $x the same way an === null guard already did, so the common resource-handle idiom (finfo_open(), pg_connect(), …) resolves to its non-false type after the guard; and narrowing a declared class by instanceof to an unrelated interface it doesn't implement (a mock that is both simultaneously) now produces the intersection the value actually has, rather than a Foo|Bar union that neither member alone satisfies.
  • A value read out of an array literal keeps the value it was written with. A function whose @return value-of<T> projects out of a template bound from the argument, as in firstValue(['low' => 1, 'high' => 10]), produced the right shape of type but not the right precision: each element was widened to its scalar type before the union was formed, so the call came back as plain int instead of 1|10. Passing that on to something declared as 1|10 was reported as a type error, and hover described the call more loosely than the caller had written it. Array literal elements now keep their own literal value, matching how the literal's keys are already carried through key-of<T>. A value that is not a literal, and every other way a template picks up a type, are unaffected.
  • Passing on what a void call gives back is reported. A function or method declared void hands back no value, so takesString(logRequest($request)) is a misreading of the API that PHP 8 covers up by substituting null at the call site. Nothing said so: a call to something declared void was resolved to that substituted null rather than to the void it declares, and void was skipped outright as an argument type on the grounds that it should never turn up as one. Between them the mistake only surfaced where the parameter happened to reject null, and stayed silent wherever it accepted one. A call now carries the void its signature declares, so passing it anywhere a value is expected is reported, with a message that says the expression returns no value rather than naming a type and leaving the reader to work out what it means. The same type reaches everything else that reads a call's result, so a variable assigned from a void call reads as void in hover and reports the member access it cannot answer against void instead of against a null the code never wrote. never is untouched: a call that does not return hands the parameter nothing because the program does not get that far, which is sound for any parameter type. A parameter annotated void is left alone too, since nothing produces a value of that type and the annotation is what is wrong there, not the argument.
  • interface-string is held to naming an interface. The refinement was parsed and displayed, but nothing enforced what it says: interface-string was compared as though it were class-string, so passing the name of an ordinary class satisfied it, and, in the other direction, passing the name of an interface where an interface-string was declared was reported as a mismatch because a class-string<SomeInterface> had no relationship to it at all. Both sides are now decided by what the name refers to, since that is the whole point of the spelling: SomeInterface::class is accepted, SomeClass::class is reported even when the class implements the interface, and the same goes for a name written as a plain string. A name we cannot load may well belong to an interface in a file nobody indexed, and a bare class-string says nothing either way, so neither is reported.
  • A class declared in two files survives the file that won it dropping the name. A package that ships a class twice, typically a variant behind a class_exists guard, settles on one of the two declarations, and only that one was kept. So when the file holding it stopped declaring the name, because the class was renamed, the file emptied, or the file deleted, the name became unresolvable even though the other file still declared it, and every use of the class was reported as not found until something happened to re-parse the survivor. The runners-up are now remembered, the way they already were for duplicate functions, so the name is handed to the next file that declares it, along with the members completion and hover read from it, its place under its parent in go-to-implementation, and the file go-to-definition opens. The two paths that index classes, the one for files open in the editor and the one that loads vendor code, stubs, and files re-opened after closing, also disagreed about duplicates: the second kept the first file it saw for a name but the last set of members, so go-to-definition could open one declaration while everything else described the other. Both record declarations the same way now, so a duplicated name resolves to the same file whichever path indexed it.
  • A function declared in two files resolves to the same one on every run. A package that ships a helper twice, typically a native implementation alongside a function_exists-guarded polyfill with a looser signature, left the winner up to whichever indexing worker finished last. So the signature a call was checked against was decided anew each time the project was indexed, and running analyze twice on unchanged code reported an argument type mismatch on one run and nothing on the next, which reads as a flaky analyzer. Duplicate declarations now settle on the same file every time, and go-to-definition lands on that declaration rather than on a different one each session. The runners-up are still remembered, so editing or deleting the file that won hands the name to the next file that declares it instead of the function going missing until something happens to re-parse it. Duplicate classes were already settled deterministically, but the tie-break only held until one of the files was parsed again on its own: re-parsing the losing copy dropped the winning declaration out of the index entirely, taking the class's members with it, and left go-to-implementation no longer listing the class under its parent. Those indexes now agree on one declaration and keep agreeing across re-parses.
  • A @template bound from several parameters is what all of them have in common. @param T[] $first, @param T[] $second states that both arrays hold the same thing, and the call that establishes what that thing is provides every element of it. Only some of the binding sites unioned, though, so the rest overwrote each other and T became whichever argument happened to resolve last. combine([1, 2], ['a', 'b']) bound T to string from the second argument and then reported the first for not being one, which measures an argument against a type taken from its sibling rather than against anything the signature declares. Every binding site now unions, on array element and positional generic parameters as it already did on direct ones, so T there is int|string and both arguments satisfy it. An empty array literal contributes nothing to the union rather than dragging the whole template down to never, and the return type a multi-bound template feeds is the union too.
  • Passing a generic class with the wrong type argument is reported. Box<string> where a Box<int> is required got as far as the class-hierarchy check, which compares the two by name, decides Box is a Box, and never looks at what is in the brackets. So the one thing a generic annotation exists to state was the one thing nothing was held to, on arguments, returns, and property assignments alike. The type arguments are now compared as well, and a value whose type arguments cannot be reconciled with the declared ones is reported with both spelled out in the message. What we cannot prove still passes: an argument our inference had to widen, one a @template-contravariant declaration means to be wider, and a class named without its type arguments at all all stay silent, as does a name that could be the same class written unqualified.
  • {@see method()} written inside its own class no longer reads as a missing global function. A bare name() in a @see tag was resolved as a global function and nothing else, so a class docblock that pointed at one of the class's own methods, which is how phpDocumentor and PHPStorm read that shape, was reported as "Function 'name' not found". Such a reference is now looked up against the class the docblock documents first, own members and inherited ones alike, and only falls through to a global function when the class has none by that name. Hover and Ctrl+Click follow it to the member. A reference that names neither is still reported, a docblock that documents something other than a class does not borrow the members of whatever declaration comes after it, and PHPUnit's @covers keeps its own reading, where a bare name means a global function and ::name means the test class's method.
  • A check on $a->value no longer keeps narrowing it after $a itself is replaced. An instanceof on a property path or on an argument-less call (if ($a->value instanceof StringExpr), if ($a->get() instanceof …)) was found by searching the enclosing body for the check, which read conditions and nothing else. Assigning a new object to $a in between left the check standing, so the code after it resolved $a->value as whatever the old object's property had been narrowed to, and a member only that type has went unreported. A write to the value a path is read from now ends the checks written before it, which is the same rule the type engine applies to plain variables, so the members of the new value are what the code after the assignment is measured against. A check written after the assignment describes the new value and still applies, as does a check on the path itself.
  • Find References matches a member's receiver by its type, never by its name. A member access reached through a property ($this->context->getAll()) or a call (makeHelper()->run()) was not resolved at all in the reference search: only bare variables and keywords were, so every such call site depended on a fallback that compared the receiver's spelling against the class names in the target's hierarchy. That fallback counted any $context->getAll() anywhere in the project as a reference to Context::getAll() once the receiver's type was unknown, whatever $context actually held, and Rename, which runs the same search, would rewrite such a call site along with the real ones. Receivers now resolve through the same chain-resolution pipeline completion and hover use, so the references the name matching was carrying are found because their types actually resolve, and the fallback is gone: a receiver that genuinely cannot be typed no longer produces references or rename edits on a spelling coincidence. The deprecated-usage check reads receivers through the same path now, so a deprecated method reached through a property or a call chain (request()->get(...)) is reported where before the receiver's type was silently given up on.
  • An untyped property takes its type from what its constructor and setters assign it. private $context; next to a setContext(Context $context) that assigns it resolved to nothing, so completion, hover, diagnostics, and Find References all went blind one link into the chain. The property now takes the type of what is assigned to it, the way PHPStan and Psalm read it: a typed parameter or a new ClassName() assigned in the constructor or a setter supplies the type, assignments of different types in different methods union, and a property with a native or docblock type keeps it untouched.
  • A generic class named without its type arguments no longer hands back its own template parameter. @var ItemCollection $items, a parameter typed ItemCollection, a return type spelled without generics: anything that names a generic class rather than instantiating it left its @template parameters standing, so $items->first(), declared @return TModel|null, resolved to a class called TModel that exists nowhere. Every member read off such a subject came back unresolved, which is how a Laravel controller passing a collection entry to an unannotated Blade view ended up reporting each of the template's reads as unverifiable. An unsupplied template parameter is now the widest type the declaration guarantees: the bound its @template declares (@template TModel of Item gives Item), or mixed when it declares none, matching what instantiating the class with new already produced. Inside a class's own body its parameters stay in scope, since there a member typed TModel means whatever the caller bound it to.
  • analyze and fix no longer silently drop a PATH argument typed relative to the working directory. phpantom_lsp analyze --project-root conformance conformance/tests/x.php, exactly what shell tab-completion produces, resolved PATH against --project-root rather than the working directory, so it looked for a doubled-up path that never existed and reported "No PHP files found" with exit code 0, the same result a clean run gets. PATH is now resolved against the working directory, and a path that still resolves to nothing is an error on stderr with exit code 2 for analyze (distinct from 1, which means diagnostics were found) or 1 for fix.
  • A completion item in a Blade template no longer edits a position several lines below where the cursor sits. Completion runs on the virtual PHP a .blade.php file preprocesses into, and go-to-definition, hover, inlay hints, and diagnostics already translate their answers back to the template's own coordinates before returning them; completion never did. Every strategy that inserts through a TextEdit, from a view name inside @include('|') to a Laravel string key, an array-shape key, an Eloquent column, a request key, or an imported class's use statement, carried an edit range from the virtual file instead, landing several lines below the prologue Blade injects ahead of the template and shifted along the line by the width of whatever the directive compiled to. Completion items now translate the same way the other features do, and one whose edit would still land inside the injected prologue is dropped rather than clamped to the start of the template.
  • An object{prop: Type} shape now matches a class or object literal that actually has that property, instead of rejecting every argument. @param object{foo: int} $shape is a structural constraint, but nothing checked a concrete class or an anonymous (object) [...] literal against it: both takesObjectShape(new Reading()) and takesObjectShape((object) ['foo' => 1]) were reported as a mismatch even when Reading declares public int $foo, with the same message a genuinely wrong argument gets. A class or object literal is now checked property by property against the shape, so a matching public property satisfies it, a property of the wrong type or a missing property is still reported, and the two are no longer indistinguishable in the diagnostic.
  • A class named Integer, Boolean, Double, or Resource is no longer read as PHP's scalar alias of the same name. None of integer, boolean, double, or resource is a reserved PHP keyword, so a project may declare a real class with one of these names, the way it already could with Number or Real. A @param Integer $value annotation naming that class was resolved as PHP's integer alias for int instead, so passing an actual Integer instance was reported as a type mismatch. Only the exact lowercase spelling now reads as the scalar alias; any other casing resolves to the class.
  • key-of<T> no longer widens to a template's declared bound when the argument is an array literal. A @template T of array<array-key, mixed> parameter binds T from the argument it is called with, but an array literal (['debug' => false, 'verbose' => true]) only ever resolved to the bare array keyword, so T bound to the erased bound rather than to the literal's own keys, and a key-of<T> return type had nothing to project them from: a call passing a key the literal never declared was silently accepted rather than reported. T now binds to the literal's actual array{...} shape when nothing else narrows the argument, so key-of<T> and value-of<T> resolve to the literal's real keys and values at the call site.
  • A PHPDoc pseudo-type with a no model found is no longer enforced as though it were a class. PHP identifiers cannot contain a hyphen, so a spelling like pure-callable, literal-int, stringable-object, or decimal-int-string can only ever be a pseudo-type. Each one was resolved the way an unknown class name is instead: qualified against the file's own namespace, reported as a missing class on the docblock, and then enforced literally, so every call site of the annotated function was reported as passing the wrong thing, valid or not, and the genuinely wrong calls got the same message as the rest. A spelling nothing recognises now reads as mixed and is not enforced at all, and one whose refinement alone is unmodelled widens to the type it refines, so a pure-callable parameter still rejects an argument that is not callable. key-of<…> and value-of<…> over a concrete array or shape are evaluated where they are written rather than being carried around as unresolved type expressions nothing can match: a value-of<array{a: int, b: int}> parameter takes an int and reports a string. Where the operand cannot be read, the operator widens rather than rejecting.
  • Inlay hints went stale after editing until the file was reloaded. The didChange handler asked the editor to re-pull semantic tokens after a background parse but did not do the same for inlay hints, so parameter-name hints, closure-type hints, and reference counts kept showing pre-edit data until the viewport scrolled or the file was manually reloaded. Contributed by @calebdw.
  • @phpstan-type aliases on traits and enums were flagged as unknown classes. A type alias defined via @phpstan-type on a trait or enum docblock and referenced in a @param, @return, or generic type parameter was reported as "Class not found," because the parser discarded those aliases instead of storing them the way it already did for classes and interfaces. Contributed by @calebdw.
  • A check on a method call narrows the call, not just a variable. if ($this->getHttpKernel() instanceof TerminableInterface) { $this->getHttpKernel()->terminate(...); } is how the check-then-use idiom is written when the value lives behind a getter rather than in a local, and the second call was resolved from the method's declared return type as though the check above it had not happened: terminate() was reported as missing on the interface the getter declares. An argument-less call is now a narrowing subject of its own, so the repeated call carries what the check proved, through instanceof, assert(), is_a(), and a plain truthy or null test. A check against a wider type leaves the declared return type alone rather than replacing it, a call that takes arguments is left out (its arguments say it does something rather than hand back state), and each check decides only its own branch, so a later, unrelated check on the same call starts from the declaration again. Completion, hover, and go-to-definition read the narrowed type too. Closes #333.
  • An array-key argument is judged as the int|string it is. A key read out of a foreach over an array whose key type is unknown is array-key, and passing one to a function declaring string was reported as a mismatch even though the same value written as int|string, or as a plain int, was accepted: outside declare(strict_types=1) PHP coerces the int half to a string, so there is nothing to report. array-key is now expanded before the check, the way the subtype relation already expands it, so the three spellings of the same type are judged alike.
  • A /* … */ comment between two annotated assignments no longer resurrects the first one's type. Reassigning a variable under a fresh /** @var Type $x */ is how a script narrows it a second time, and the later annotation is meant to win. The scan that finds the annotation looked backwards for the nearest /**, which walked straight past an ordinary block comment sitting between the two statements and read the earlier annotation as though it were the one attached to the assignment. The variable then carried the type it had before, so hover named the wrong class, completion offered its members, and a member of the type actually assigned was reported as missing. A block comment now ends the search where it stands, and where several annotations really do stack up, the one written closest to the code is the one that applies.
  • A branch PHPantom cannot type widens the answer instead of disappearing from it. Reading a key off a value typed array|string|null resolved to nothing at all, and a ternary branch that resolves to nothing contributed nothing to the union built from it, so $x = array_key_exists('k', $service) ? $service['k'] : 'exception' came out as exactly 'exception': not a wider answer than the truth but a narrower one, stated with the confidence of a complete one. Hover named a value the variable often does not hold and completion offered that value's members. A union is now read one member at a time and the results joined, so the array half yields its element type, the string half yields string, and the null half yields null; a branch that still cannot be typed contributes mixed rather than vanishing. Reading an offset off a plain string is typed too, and gives string. A branch that produces no value at all, such as a throw arm in a match, is unaffected: it never reaches the union.
  • An inline @php(…) no longer hides the rest of the Blade template. Blade spells @php two ways: the block form, which runs until @endphp, and the inline @php($featured = $posts->first()), which closes with its own parenthesis. PHPantom treated both as block openers, so an inline one blanked everything from itself to the next @endphp anywhere in the file, or to the end of the file when there was none. Whatever fell in that span stopped counting: a template's @extends, its @props and @aware declarations, and, most visibly, every <x-…> tag written after it, whose component then saw none of the attributes that call site passes and reported the variables they supply as undefined. The inline form is now inert only as far as its own closing parenthesis, as it is to Blade, and the assignment it holds updates the template's scope the same way the block form does.
  • Every Blade directive Laravel ships is now recognised, and one where its arguments were going untyped is type-checked. @can/@cannot/@canany and their @elsecan…/@endcan… counterparts, @lang/@endlang/@choice, @unset, and @js/@vite/@viteReactRefresh/@fonts/@dd were not directives PHPantom knew about at all, so a template that wrote one was left as inert, unprocessed markup rather than analysed. @pushIf, @pushOnce, @prependOnce, @hasStack, @hasSection, and @sectionMissing were recognised but degraded to a bare comment, so a variable used only inside one of them was invisible to the forward walker and reported unused; the last three always close with a literal @endif rather than a directive-specific closer, so the comment also left that @endif dangling with no matching if, breaking the rest of the template's analysis. Every one of these now has a real translation whose arguments are read as genuine PHP expressions.
  • A callback's return type binds every template it names, not the first one only. A method that types its callback as @param callable(TValue): (Collection<TFlatMapKey, TFlatMapValue>|array<TFlatMapKey, TFlatMapValue>) $cb, the shape Laravel's Collection::flatMap() takes, bound both templates to whatever the closure's own return annotation said. A closure written fn ($c): array => arrStr($c) says only array, so the key type and the value type both came back as that bare array, and every later call on the result was checked against it. The callback's return type is now matched against the shape the parameter declares, so each template binds to the part of it that names it, and when the closure's annotation says less than its body does the body is what gets decomposed. flatMap(fn ($c): array => arrStr($c)) over a helper returning array<int, string> now gives a collection keyed by int holding string. Closes #332.
  • Renaming a class no longer writes into Blade templates that merely receive it. To analyse a template PHPantom prepends a block of declarations to it: $errors, $__env, the @var tags carrying the types of the variables the template is passed, and, for a template rendered with $this bound, a wrapper class extending the component. Those name real classes, so a class a template only ever receives, never spells out, still counted as used there. The match had no template text behind it and was reported at the very start of the file, which made Find References list templates that do not mention the class at all and, worse, made Rename insert the new name at the top of each one. Positions in that prepended block are now dropped rather than pinned to the first character, so references, rename, prepare-rename, document highlights, inlay hints, and semantic tokens all stop at the template's real content. Rename also translates edits it delivers as document changes, which is the form it takes when the class file is renamed alongside the class.
  • A Blade layout chosen with @extendsFirst is no longer invisible. Blade picks the first template that exists out of the candidates @extendsFirst(['themes.dark', 'layouts.app']) lists, and @componentFirst does the same for a component. PHPantom recognised neither, so a page built that way lost its layout entirely: the layout's @var declarations never reached the child, the variables only the layout reads were reported as ones the view has no use for, and a $user passed to the directive counted as unused. Both directives are now read like the @extends and @component they stand in for, over every candidate they name, and a candidate list assembled at runtime stands the checks down rather than guessing at one.
  • Laravel's keyBy(), groupBy(), and mapWithKeys() rebind a collection subclass's key type correctly. A collection subclass that fixes its key and value types purely through @extends (the shape Eloquent's own collection classes take) kept its old key type after a re-keying call, because the rebind had nothing to substitute against: the subclass declares no @template of its own, only an @extends binding. A keyBy() call with a string-returning callback on such a subclass still checked the next lookup against the stale, original key type. mapWithKeys() had a second, separate bug: it bound the new key to the callback's whole return type instead of that array's key, rejecting valid string keys outright. Both are fixed: the rebind now re-applies through the subclass's @extends binding, and mapWithKeys() destructures the callback's array-shaped return into its key and value.
  • Variable resolution no longer recurses when building the top-level scope for global. A file containing a global declaration plus top-level call arguments that require variable resolution could re-enter the same scope construction, restarting the full top-level walk from every nested query until the request hung. The walk is now guarded against re-entry on the same file content, and a query-level guard prevents the exact same variable resolution from re-entering itself through indirect call paths. Closes #327.
  • A @var whose type is a closure signature binds the right variable. /** @var \Closure(\App\Models\User $user): string $callback */ read $user, the closure's own parameter name, as the annotated variable, leaving $callback untyped and adding a bogus $user to scope. The same shape decides which names a Blade template's signature docblock declares, so a component contract that documented a closure prop lost that prop's name entirely. The scan now tracks paren and angle-bracket depth while walking the type, so it stops at the $name that actually follows the type rather than the first $ it sees.
  • A dotted Laravel container key no longer resolves to a class named after its first segment. app('demo.bakery') could resolve to an unrelated project class named Demo instead of the class the container key was actually bound to. A container key is never a valid PHP class name, but it was normalized through the same parser used for type hints, which stops reading an identifier at the first character it cannot contain, silently truncating demo.bakery down to demo. A key containing a character no PHP identifier can hold is now looked up directly against Laravel's own alias tables instead.
  • A callback parameter is typed when the array argument is an inline call to an array function. array_map(static fn (array $case) => $case[0]->name, iterator_to_array(self::cases())) left $case as a bare array, so $case[0] had no type and member access on it was reported as unverifiable. Assigning the inner call to a variable first worked, and so did passing a call that needed no element-type inference. The element type the array functions compute for an inline call (iterator_to_array(), array_values(), array_filter(), ...) was being overwritten by the bare array the stubs declare, so nothing was left for the callback's parameter to narrow to. The computed type now survives, which also gives a more precise type to anything else reading these calls inline.
  • A container call through the App facade resolves when it is chained directly. $repo = App::make(EventRepository::class); followed by $repo->getActiveEvents() resolved, but the one-line App::make(EventRepository::class)->getActiveEvents() did not, and neither did App::makeWith(...)->run(). A facade forwards its static calls to a container class, and only the assignment path knew to look past the facade's own @method tag (which flattens the container's argument-dependent return to object|mixed) to the concrete class that actually types the call. The chain resolver now makes the same jump, so both spellings resolve, matching the app() helper.
  • analyze reports the same diagnostics on every run. Two runs over an unchanged directory could differ by dozens of messages, which made it impossible to tell a real regression from noise when comparing two builds over a corpus. Three things let a file's result depend on what the parallel workers happened to reach first. Bundled stubs were the only files with no protection against two workers parsing them at once, and the worker that finished second took the re-parse path, discarding every already-resolved class that depended on the stub. An interface was merged into an implementing class in full when it was already cached and as a weaker approximation when it was not. And a class declared in more than one file, as Carbon's DatePeriodBase and Symfony's polyfilled RoundingMode are, resolved to whichever copy was parsed last, so a name could pick up the legacy variant of a class that also ships a modern one. Results no longer depend on the worker count either, so machines with different core counts agree.
  • A static factory's method-level template survives into a directly chained call. A factory such as Collection::make($items), declared @template TValue with @return static<array-key, TValue>, bound its element type when the result went through a variable but lost it when the call was chained straight on: the static path read the factory's declared return type without applying the bindings it had just computed, and then flattened static<…> to a bare class name, dropping the arguments with it. Both now happen the way they already did for an instance method, so Wrapper::make(names())->push([1]) reports the same argument mismatch that the two-line form does.
  • A standalone @var docblock narrows a call inside the same echo, if, or other non-expression statement. A /** @var Collection<string, Loaf> $byName */ written on its own line, immediately followed by a statement other than a bare expression (an echo, an if, a return, ...), correctly typed the variable everywhere after that statement but not within an expression inside the statement itself: a diagnostic scope snapshot taken right before the docblock was applied never got refreshed, so a call reached through the annotated variable saw its bare class instead of the generic arguments the annotation gave it and fell back to the class's declared template bound. Every Blade {{ $byName->get(...) }} compiles to exactly this shape (echo e( $byName->get(...) );), which is how this most commonly surfaced.
  • A callback body that is a call binds the template it returns. An unannotated callback takes its return type from its body, and a body that was a call had none to give: only classes came back from that step, so ->keyBy(fn (Review $r) => $r->getRating()) left the key template unbound and a later $byRating->get(1) was reported as expecting array-key|\UnitEnum|null instead of int|null. A call body now resolves to whatever the callee returns, scalars included, the same way a property read or a new expression already did.
  • A union parameter hint binds through the alternative the argument matches. A parameter that accepts either an element or a container of elements, as Laravel's Collection::wrap() does with @param iterable<array-key, TWrapValue>|TWrapValue $value, always bound the template to the whole argument, so wrapping a string[] gave a collection of string[] rather than of string. The alternatives are now tried against the argument's actual shape, and the bare one, which matches anything, is only used when none of the others fit. Key and value positions line up across container shapes too, so a list<string> argument binds a TKey/TValue pair correctly instead of leaving both at their declared bounds.
  • A re-keying callback rebinds the key type of the Laravel collection it returns. Collection::keyBy() and every other method whose return type is bound to a callback's return type (@param callable(): TNewKey) missed that binding in two common cases, and fell back to the template's declared bound, so a later $keyed->get('slug') was reported as expecting int|null or array-key|null. A callback written static fn (…) => … or static function (…) { … } is now read as the closure literal it is, rather than being skipped over the modifier. And a callback with no return-type annotation now keeps its body through call-chain resolution, so ->keyBy(fn ($row) => $row->slug)->get('slug') binds the key type from the body the same way an annotated callback binds it from the annotation.
  • A static call through a string-typed subject is no longer reported as scalar access. $string::method() is valid PHP: the string is read as a class name at runtime. PHPantom treated it the same as $string->method(), which really is a crash, and reported Cannot access method 'method' on type 'string' for both. A :: access on a subject whose only possible type is string (a bare variable, a property, a return value) is now left unresolved instead, since PHPantom cannot verify the class name a running program would supply. A subject typed class-string<T> goes further: :: now resolves against T itself, so $job->class_name::dispatch() completes and type-checks against the job class the property names. Other scalars (int, bool, …) can never name a class, so $intVar::method() still reports the scalar-access error.
  • A class in a file's global namespace { } block keeps its global name. A file that pairs an anonymous namespace { ... } block with a named one, as the bundled PDO stub does, labelled the classes in the global block with the sibling namespace. PDO was reported as Pdo\PDO, so hover, go-to-definition, and diagnostics all named the wrong class ('Pdo\PDO::sqliteCreateFunction' is deprecated). Each block's classes now carry the namespace they were actually declared in.
  • An array function keeps its element type when the call is used inline. $rows = iterator_to_array($it); $rows[0]->name resolved, but writing the same thing in one go as iterator_to_array($it)[0]->name did not: the element-type rules for the array-producing standard library functions only ran when the call was assigned to a variable, and every other position fell back to the bare array the stub declares. Those rules now apply wherever the call appears, so indexing straight into array_map(), array_filter(), or iterator_to_array() resolves, and so does nesting one inside another. A conditional or generic return type resolved from the call's arguments is now reported alongside the classes it names, so a call that resolves to an array shape or a list of scalars is no longer flattened back to its declared type.
  • A closure parameter declared as plain array narrows to what the call site passes. A callback handed to array_map(), array_filter(), or any method whose parameter is typed callable(T) may declare its own parameter with the widest hint PHP has a keyword for, since PHP itself cannot express the element type. PHPantom took that hint at face value and threw away the element type it had already worked out, so static fn (array $case) => $case[0]->name over a array<array{DiscountType, string}> left $case[0] with no type at all and every member reached through it was reported as unverifiable. A bare array or iterable hint now yields to the element type the call site knows, while a hint that says anything the call site does not (a class name, a union, an element type of its own) still wins.
  • App::make(), App::makeWith(), and App::resolve() resolve a class-string argument to that class. app(CurrencyHelper::class) and app()->make(CurrencyHelper::class) already resolved to the concrete class, but the App facade did not: App::make(CurrencyHelper::class)->format() reported the member as unresolvable. Two issues combined to hide the underlying container's argument-dependent return type: the facade's own @method docblock tag flattens it to a bare object|mixed, and the container-binding key App::getFacadeAccessor() returns ('app') is registered against self::class in the framework's own alias table, which PHPantom discarded as unresolvable. App::make()/makeWith()/resolve() now fall through to the real Container/Application declaration whenever the facade's own signature does not narrow the return, and self::class/static::class entries in the core container alias table resolve to the class whose source is being parsed.
  • An assignment through a by-reference closure capture is no longer lost. A closure that writes to a variable captured with use (&$var) updates that variable whenever it runs, but PHPantom only credited the write when it could prove the closure ran before the call returned, and it could rarely prove that: a chained receiver such as Db::connection()->transaction(…), a closure stored in a variable and called later, or an unresolvable callee all left the outer variable at its old type. A $var !== null check then appeared to narrow null to nothing, and passing the variable on was reported as a type mismatch. The types a by-ref capture assigns now widen the outer variable even when the invocation cannot be proven, matching how PHPStan treats such captures, and a provably immediate invocation (an immediately-invoked function expression, or a callable parameter considered immediately invoked) still replaces the type outright. Closes #329.
  • A check stored in a variable still narrows. $isHtml = $raw instanceof HtmlString; carries the check, so if ($isHtml), $isHtml ? … : …, and a !$isHtml guard clause should all narrow $raw the way the original expression does. PHPantom only narrowed the expression written in place, so every member reached through the subject behind the boolean was reported as unresolvable. The boolean now stands for the check wherever it is tested, in diagnostics, completion, hover, and go-to-definition alike, and stops doing so once the boolean or its subject is written to.
  • isset() in a short-circuit condition now marks the variable defined for the rest of the chain. isset($x) && $x == 1 only evaluates its right-hand side once $x is known to exist, and !isset($x) || $x == 1 likewise, but PHPantom still reported $x as undefined in both. This shape is common in Blade templates (@if (isset($isOutlet) && $isOutlet == 1)), where it produced several false positives per file. A read anywhere later in the same &&/|| chain as a guarding isset()/!isset() is no longer flagged; a plain if (isset($x)) { ... } still leaves $x undefined in the body, since isset() alone does not define it.
  • A @method tag no longer overrides a method that really exists. PHP only reaches __call() when no accessible method is found, so a @method tag naming something a parent or trait already declares never takes effect at runtime. PHPantom honoured the tag anyway, replacing the real signature with whatever the tag said. A test base class that documents @method MockInterface mock(string $abstract) alongside Laravel's inherited mock() was enough to throw away the framework's precise type, so $this->mock(Client::class) came back as a bare Mockery\MockInterface and returning it from a helper declared Client&MockInterface was reported as a type error. The real method now wins, and a @method tag applies only where no such method exists.
  • A standalone @var block keeps its variable in scope for the rest of the body. An annotation that stands on its own, like the /** @var App\ViewModels\ShowViewModel $model */ a Blade template opens with, was only picked up when it sat directly above an assignment. Anywhere else, a // short comment written under it or an if block written above the use site was enough to lose it, and every member reached through the variable from that point on was reported as unverifiable. Such a block now declares the variable the same way an annotated assignment does, so it survives intervening comments and any number of sibling blocks, in Blade templates and in ordinary PHP alike.
  • Imports written inside a Blade template are honoured. Laravel compiles a template's @php and <?php regions into the top level of the generated view file, so a use App\Helpers\CurrencyHelper; written in one imports for the whole template. PHPantom never registered those imports, so CurrencyHelper::formatPrice(…) was flagged and, worse, anything assigned from the short name was left untyped, which took every property, loop variable, and @var derived from it down with it: one view produced 17 diagnostics from a single unrecognised import. The same imports now populate the template's import map whichever way they are written, including the @use('App\Models\Post') directive, which was hoisted to a point in the generated PHP where it no longer applied to the template body. As a result an import nothing in the template references is now correctly reported as unused.
  • $this inside a Laravel macro closure resolves to the target again in diagnostics, hover, and go-to-definition. Laravel binds a ::macro('name', function () { … }) closure to the concrete class it was registered against, so $this inside Route::macro('auth', function () { $this->get(…) }) is the router, not the service provider the registration is written in. Completion already resolved this correctly, but every other consumer built its own resolution context without the same lookup, so unknown-member and deprecated-usage diagnostics flagged every member call on $this in a macro body, and hover and go-to-definition fell back to the enclosing class. The lookup now lives on Backend and is shared by every consumer.
  • A Laravel view name written with / separators resolves. Laravel's view finder accepts view('redirects/create') and @include('partials/lux-popups/modals/_card') the same as the dotted spelling, and tolerates a leading slash, but PHPantom recognized dotted names only and reported the rest as unknown views. A view name is now canonicalized wherever it appears, so both spellings hover, navigate, complete, and feed Blade call-site inference as the one template they name.
  • A comment no longer hides a Laravel request field's receiver, including through the safe() hop. $request /* the request */ ->input('|') and $request->safe() /* validated */ ->only(['|']) offered no field completion, because the receiver was recovered by a backwards text walk that stops at a comment's closing */. It now comes from the same forward scan used elsewhere, which sees past the comment and, for the safe() hop, through the closed call to the request it narrows.
  • A comment before the arrow or double colon no longer hides a string-argument call's receiver. $q /* the query */ ->where('a') and User /* the model */ ::with('a') offered no completion inside the string, because the receiver was recovered by scanning raw text backwards from the operator, which stops at the first byte that cannot continue an identifier, the closing */ of the comment. The receiver's boundary now comes from the same forward reading of the file that already finds the call and its argument list, so it sees past the comment to the receiver behind it.
  • auth()->user() and Auth::user() resolve to the configured model again. The guard-aware resolution covered $request->user() and the guard-argument spellings (auth('admin')->user()), but the two most common entry points slipped through. A no-argument auth() returns the Factory contract, which declares no user() at all, so every member call on it reported "Method 'user' not found"; the contract now carries the concrete AuthManager the container binds to it, whose @mixin Guard forwards user() and friends to the default guard. And the Auth facade declares user() only as a @method docblock tag, which the model refinement never touched, so it stayed at the bare Authenticatable contract; the tag's return type is now refined to the default guard's configured model like the real methods are. Completion, hover, go-to-definition, and diagnostics on auth()->user()->email and Auth::user()->email all see the concrete model. Closes #298.
  • A comment no longer displaces the call a string argument belongs to. The literal the cursor is typing in is found by reading the file forward, but the call around it was still recovered by scanning backwards over raw text, where a comment reads as code. A bracket in one unbalanced the search for the call's opening parenthesis and the completion dropped out altogether ($query->where('a' /* ) */, 'b')), and a comma in one counted as an argument separator, so what was offered were the suggestions for a later parameter ($query->where('a' /* , */, 'b') was read as the third argument). The call, the argument index, and the method name now all come from the same forward reading, so Eloquent columns and relations, request input keys, and model-property parameters complete in those positions, a comment between the method and its argument list ($query->orderBy /* asc */ ('…')) no longer hides the call, and neither does an argument list more than a couple of thousand characters long, which the backwards scan gave up on.
  • Generated PHPDoc types now use the file's use imports instead of the fully qualified name. @param, @return, and inline @var completion (and the "Update Docblock to Match Signature" and "Extract Function/Method" code actions) enrich a type with its @template parameters, e.g. Collection<TKey, TValue>, but always spelled the class name out in full even when the file already imports it: App\Collection<TKey, TValue> instead of Collection<TKey, TValue>. Generated types are now shortened through the same use-map and namespace lookup the class-name completion path already used.
  • A comment or a line break no longer hides the string the cursor is typing in. The literal a string completion belongs to was found by reading the cursor's own line for quotes without regard for comments, so an apostrophe in a note earlier on that line paired up with the real opening quote and left the cursor looking like it sat outside a string. Artisan::call('app:sync', [ /* don't ( */ ']) offered no parameter keys, and neither did anything else that completes inside a literal. Anchoring to the line was also why a call broken over lines ($request->input( and the key on the next line) offered nothing. The literal now comes from the same forward reading of the file the surrounding code already uses, so command and route parameters, request input keys, Eloquent columns, and Laravel route, config, view, and translation keys all complete in those positions.
  • A conditional buried in a generic return type is decided at the call site. $collection->groupBy('key') returns Collection<($groupBy is array|string ? array-key : TGroupKey), …>, and the conditional travelled on as-is into the key type of the result, so the next call in the chain compared its argument against a type expression no value can match: Argument 1 ($key) expects $groupBy is array|string ? array-key : array-key, got 'bucket'. The condition is now decided against the arguments of the call it belongs to, wherever it sits inside the return type. A conditional that genuinely cannot be decided is compared as the union of its branches, which is what the value satisfies either way, and is reported that way too.
  • A generic class is no longer rejected by a parameter typed with that same class. new Decimal('0.00'), where Decimal declares a @template parameter that the constructor does not bind, resolves its template arguments to their declared bounds and became Decimal<bool>. That type carried only the class's short name, which nothing outside its own namespace can resolve, so passing the value to a Decimal $amount parameter reported expects Acme\Decimal\Decimal, got Decimal<bool>. Such a type now carries the fully qualified name and matches the parameter, and a generic type whose name the project genuinely cannot load no longer produces a mismatch at all.
  • A comment no longer stops Laravel route and Artisan command parameter keys from completing. The keys of route('users.show', ['user' => 1]) and Artisan::call('app:sync', ['user' => 1]) were found by reading the text before the cursor from right to left, which cannot tell a comment from code: a note holding a bracket, a parenthesis, or an apostrophe between the call and the key unbalanced the reading and no keys were offered. That text is now read in the direction PHP reads it, so comments (//, #, /* … */), heredoc bodies, and the HTML around a <?php block are all seen for what they are.
  • Type casts in ternary and conditional branches are now resolved. $x = isset($a) ? (int) $a : null inferred only null instead of int|null, because a cast carried its type only when it was the entire right-hand side of an assignment. This produced false-positive argument type mismatches once the variable reached a position that required the non-null branch, such as $x === null || !take_not_null($x). The unary ! and ~ operators were affected the same way, and (object) in a branch now yields the same object shape it does in a direct assignment.
  • A union merged out of a one-sided if is listed in source order. A variable assigned before an if and reassigned inside it hovered as the in-branch type first, so $x = new Foo(); if (…) { $x = new Bar(); } showed Bar above Foo. An if/else where both branches assign has always rendered in source order, so the two shapes disagreed on which type reads as the headline. The pre-branch type now comes first in both, and go-to-definition on a member both types share follows the same order.
  • Nested @param-closure-this closures resolve $this to the innermost binding. With a closure passed to a call inside another such closure (a Route::group() holding a nested group, a macro registered inside another registration), $this in the inner body kept resolving to the outer call's declared type, or fell back to the lexically enclosing class. The innermost @param-closure-this now wins, at any nesting depth, and the inner call's own receiver is resolved through the binding that surrounds it, so completion, hover, and go-to-definition all see the right class. self:: and static:: inside such a closure follow the same binding.
  • @param-closure-this is found even when the outer closure is not itself a call argument. When a closure holding the call site was assigned to a variable, stored in an array, or returned from a function rather than passed directly as an argument, $this inside a call nested further in fell back to the lexically enclosing class instead of the @param-closure-this type. Such a closure does not rebind $this itself, but the call inside it is now still found.
  • A plain function body resolves class names against the file's namespace. PHP looks up an unqualified new Foo, Foo::bar(), or Foo::CONST in the current namespace before the global one, and PHPantom did that only when the reference sat inside a class. From a plain function, a closure at file scope, or top-level code it fell back to the global namespace, so Aborter::fail() inside namespace App resolved to \Aborter whenever a global class of that short name existed. Hover, completion, go-to-definition, and diagnostics now all pick the same class PHP would.
  • A guard clause written with the alternative if: … endif; syntax now narrows. if (!$x instanceof Foo): return; endif; left $x unnarrowed afterward, unlike the identical guard written with braces. The colon-delimited form now tracks which branch unconditionally exits the same way the brace form does, so a return, throw, or never-returning call inside it narrows the type that follows.
  • "Extract function" no longer breaks by-reference writes. A selection that assigns to a variable bound by reference (a &$param, a foreach (… as &$item) value, a use (&$total) capture, or the target of a $ref = &$value) was extracted like any other code. The new function received a copy, so the mutation the caller was relying on silently disappeared. Those selections are now left alone. Reading a by-reference variable is still extracted as before, and so is a write that happens before the reference is taken.
  • Calling a function or method that returns never is now recognized as an unconditional exit. Guard clauses like if (!$x instanceof Foo) { abort(); } where abort() is declared with return type never now narrow the type after the if block, and an assignment made in the branch is treated as the dead code it is, exactly like return, throw, exit, or die. Functions, static calls, and method calls all count, whether the method is declared on the class, inherited, or supplied by a trait, and local variables narrow the same way properties do.
  • A fluent chain through a union return type no longer hangs the analyzer. When every link of a method chain returns a union whose members share the method (Pest's expect(...)->and(...)->toBe(...) chains resolve to Expectation|HigherOrderExpectation at each step), each link resolved the method once per union member and kept the duplicate results, doubling the receiver set at every link. A 20-expectation Pest test built over a million receivers, pinning every core and exhausting memory until the process was killed. Duplicate classes are now dropped as each link resolves, so long expectation chains analyze instantly.
  • A union of two classes with the same short name keeps both halves. Candidate classes were deduplicated by their unqualified name, so a union spanning two namespaces that each declare a Thing (or an Exception, Client, Config, Response, which real projects have one of per namespace) collapsed to whichever came first and lost every member of the other. A @return \NsA\Thing|\NsB\Thing reported Method 'onlyNsB' not found on class 'NsA\Thing', offered only the first class's methods in completion, and had nothing to jump to on go-to-definition. The comparison is now on the fully-qualified name, so both classes survive while genuine duplicates are still dropped and long fluent chains through a union stay fast.
  • Every feature now resolves a type as well as hover does. Three parts of the type engine were switched on for hover, completion, and diagnostics only: inferring a return type from a method body when nothing declares one, resolving the model a Laravel auth guard is configured with, and reading the array shape a validation rules array describes. Every other feature asked the type engine the same question with those parts absent and got a poorer answer for the identical code, so hovering auth('admin')->user()->email named the model's property while go-to-definition on that same email had nothing to jump to. They are now active for every request, so go-to-definition, find-references, signature help, code actions, rename, and inlay hints see what hover sees.
  • "Promote to constructor property" keeps the property's attributes. An attribute on the property being promoted (#[SomeAttr] private int $bar;) was deleted along with the declaration and never re-emitted, so the refactor quietly removed executable metadata that ORMs, validators, and serializers read at runtime. The attributes now move onto the promoted parameter, ahead of the visibility keyword, one #[…] group each so a grouped #[First, Second] still reads clearly. Arguments carry over verbatim, and an attribute the parameter already has is not repeated.
  • Override completion writes static, not $this, as the return type. Completing an override of a method whose return type only exists in PHPDoc as @return $this generated public function withTitle(string $title): $this, which PHP rejects: $this is not a native type hint. The generated signature now uses : static, the native spelling of a fluent return, and the same applies to the "Implement missing methods" quickfix and to unions like $this|null. A @template param is no longer emitted as a hint either: @return T used to generate : T, which PHP reads as a return of the nonexistent class T. Completing an override of a trait method now also restates the trait's docblock-only @param and @return types (and the @template params they use) above the new declaration, since PHP inherits PHPDoc from parent classes and interfaces but not from traits. Only the types the generated signature cannot express are restated, so an override of a plainly typed trait method still comes out bare.
  • A very long fluent chain no longer crashes the language server. A generated query builder or generated API client can produce a method chain thousands of links long, and every stage that walked one, building the file's symbol map, resolving the receiver's type, rendering the expression back to text, spent a stack frame per link. Around a few hundred links that exhausted the stack, and because a stack overflow aborts the process rather than raising an error, the server died and the editor lost every feature until it restarted. Each of those walks now steps along the chain instead of recursing into it, so a chain thousands of links long resolves, hovers, and analyses without taking the server down.
  • A write through __set no longer overrides what __get returns. Assigning to a property a class only has through its magic setter ($bag->a = 9 on a class with __set, whether written from outside the class or from inside it) recorded the written value as the property's type, so the following read came back as 9 instead of the int the documented __get gives you. The setter is free to transform, reroute, or drop the value, so the write says nothing about a later read: reads now resolve through __get as they do without the write. A real declared property, an @property tag, and a dynamic property on a class without __set are all unaffected and still take the written type.
  • A long ?? chain or a deeply nested ternary still resolves. $a ?? $b ?? … ?? $z, a ternary nested in its own else branch, and stacked (…) or @ wrappers were resolved one recursive step per link, and past about a hundred links the resolver gave up and reported no type at all. Completion offered nothing on the result, hover showed nothing, and the branches beyond the cutoff were dropped from the union. All three shapes are now walked without a recursion budget, so every branch that can be reached at runtime contributes its type however long the chain is.
  • A property assigned inside a guarded if keeps that type after the block. The lazy-initialisation idiom (if (!$this->instance instanceof Concrete) { $this->instance = …; }) dropped back to the declared property type once the if closed, so returning the property from a method with the narrower return type was reported as a mismatch and completion on it offered the broader type's members. Both ways out of the guard give the same type, and the merge at the end of the block now says so. A property only narrowed inside one branch still widens back, and a merged Child|Parent union now collapses to Parent even when one side is nullable.
  • A short @implements argument list binds the value parameter. @implements Bag<User> against an interface declared @template TKey of array-key / @template TValue bound User to the key parameter while resolving @method/@property and interface members, the opposite of what the same annotation means everywhere else, so a member typed with the value parameter came out as the raw template name. The interface merge now right-aligns short argument lists the way the main inheritance merge already does, both for the interface's own generics and for the ones collected from a parent's @extends.
  • "Make constructor final" puts the keyword on the constructor. For a constructor with no visibility keyword in a class whose opening brace sits on its own line, the quickfix inserted final in front of that brace, producing code PHP rejects. The same happened when an attribute shared the constructor's line. The keyword now lands on the first real modifier, or on function when there is none.
  • "Promote to constructor property" takes the property's docblock with it. The action deleted the property declaration but left a /** @var int */ above it behind, stranded above the constructor. The docblock is now removed along with the declaration it documents.
  • analyze reports Blade diagnostics on the right line. A type mismatch, unknown member, or unknown variable inside a Blade template was reported six lines above the code that produced it, so the CLI pointed at unrelated markup. The Blade coordinate translation was applied twice; it now happens once, where every other diagnostic already gets it.
  • array_pop on a nested array unwraps one level. Popping a list<list<int>> resolved to list<list<int>> rather than list<int>, so iterating the result gave list<int> where it should give int. The same applied to array_shift and the other element-extracting functions whenever the element type was not itself a class. Popping a list<User> was unaffected.
  • instanceof narrowing applies inside a for loop's condition. for ($e = $iter->current(); $e instanceof Foo && $e->x; …) did not narrow $e for the rest of the condition, so completion and hover on $e->x saw the unnarrowed type. if and while conditions already narrowed this way.
  • extends is no longer offered while declaring an enum. Typing enum Foo ext suggested extends, which PHP rejects outright for enums; only implements is valid there. Class and interface headers still offer it.
  • A use \Foo\Bar; import no longer keeps its leading backslash. An import written with the optional leading \ was recorded with it, so the name it resolved to differed from the same class imported without it, and only some resolution paths stripped it. Imports are now normalized when they are read, and the type engine's own copy of name resolution has been replaced by the shared one so the two cannot drift apart.
  • String-argument completion no longer scans the whole file to find its call. The backward search for the ( that opens the argument list had no bound, so an unbalanced bracket earlier in the file (common mid-edit) sent it to the start of the file on every keystroke inside a string literal. It now stops after 2000 bytes, well past the length of any real argument list.
  • A changed @template T = default value invalidates its cache entry. Editing the default of a template parameter (@template TAsync of bool = false to = true) did not count as a change to the class, so conditional return types that depend on that default could keep resolving against the old value until something else in the class changed.
  • A */ inside a Blade comment no longer breaks the rest of the template. Commenting out a block of PHP is the usual reason to write {{-- … --}}, so the comment text routinely contains */. That sequence used to close the comment early, turning the remainder into live code, and everything below it in the template lost completion, hover, and go-to-definition while collecting nonsense syntax errors. The comment text is now neutralized when it is emitted, so only the --}} Blade itself looks for ends the comment.
  • Negating an integer variable stays an integer. -$count on an int resolved to int|float, which modelled the one input (PHP_INT_MIN) that overflows at the cost of precision on every other negation, exactly as PHPStan reports it. Negating an integer literal was already exact.
  • A class named Real is no longer treated as float. real was PHP's pre-8.0 alias for float, and accepting it case-insensitively meant a userland Real class (plausible in a math or finance codebase) compared as a scalar, so genuine mismatches against it went unreported. The alias now applies only to the exactly lowercase spelling, the same rule the number pseudo-type already followed.
  • String-key completion knows when the cursor is outside a string. The scan for the enclosing string literal looked backwards for the nearest quote, so the closing quote of a finished literal ($a = 'x'; $request[) was read as an opening one, and an apostrophe in a double-quoted string won over the real opening quote. Quotes are now paired in the direction they actually pair, and a route or command name written with an escaped quote is read whole rather than truncated at the escape.
  • A parent parameter type on an inherited method resolves to the right class. parent binds to the parent of the class that declares the method, but an inherited copy resolved it against the class the call was made on, so calling $leaf->take($base) on a grandchild demanded the wrong class. Both self and parent are now bound to the classes the declaration meant when the method is inherited.
  • A qualified class name resolves against the current namespace first. new View\Event() inside namespace Site resolved to a global Event class when one existed, so every member of the project's Site\View\Event was flagged as unknown. PHP prefixes the current namespace onto any name that does not start with \, whether or not it contains a separator, and an import of the leading segment (use Other\View;) takes precedence over that. Both now resolve the way PHP does, with the global scope reachable through a leading backslash. Closes #299.
  • Functions loaded through a __DIR__-relative require_once chain are indexed. Composer files autoload entries that dispatch to their real definitions with require_once __DIR__ . '/...' (as thecodingmachine/safe does to select per-PHP-version function files) were not followed, so a call like \Safe\base64_decode() was reported as "Function not found" even though it works at runtime. The autoload scanner now follows those requires. (#318)
  • A static call on an unqualified class name resolves against the current namespace first. B::x() inside namespace Src resolved to a global class B when one existed, so methods declared on the sibling Src\B were flagged as unknown. PHP resolves an unqualified class reference to the current namespace; the global class is only reachable as \B. Static access and bare class references now try the same-namespace class before falling back to the global scope, matching the existing behaviour of new B().
  • @method and @property tags on a parent's trait are inherited. A docblock tag declared on a trait was only visible on the class that used the trait directly, not on its subclasses, even though the subclass inherits the trait's real members. The tags now propagate down the whole chain, with the trait's template parameters resolved through both the consumer's @use arguments and the subclass's @extends arguments. Contributed by @shuvroroy (#314).
  • Framework internals no longer appear as properties on Eloquent models. A model that declared no relationships of its own still offered hasMany, belongsTo, morphEagerTo and the rest of the framework's own methods as properties, plus has_many_count and friends as count properties. Only relationships the model itself declares produce properties now.
  • parent::SOME_CONSTANT resolves to a type. A class constant reached through the parent keyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached through self, static, or the class name resolved normally. Constants inherited further up the chain resolve through parent:: too.
  • A template parameter bound only by the argument it type-checks no longer flags a false positive. PHPUnit's assertSame(url('/login'), $x) (and any other call where a @template is bound solely by the parameter being checked, such as assertSame's $expected) could report a type mismatch: the substituted parameter type is derived from resolving that exact argument, so comparing the argument to it again is circular and, when the two resolution passes disagree on an ambiguous expression, produced a spurious diagnostic. Such a parameter is no longer checked against its own argument.
  • self, static, and parent in a parameter type resolve to a real class. A method declared canChangeTo(self $next) used to be checked against the literal keyword, so passing an instance of the declaring class was reported as "expects self, got State". The keywords now resolve wherever the call is made from, including through a property ($this->state->canChangeTo(State::B)), where the enclosing class is not the one declaring the method. self on an inherited method binds to the class that declares it, so a parent instance is still accepted when the method is called on a subclass, and a parent parameter is now checked instead of skipped. Mismatches name the class the keyword resolves to rather than the keyword.
  • Returning a base type where a subclass is declared is now reported. Passing or returning a value whose type is a supertype of what the signature declares (an Animal where a Cat is expected) used to be waved through on the grounds that the value might be the narrower type at runtime. That silence hid a whole class of genuine mistakes, most visibly returning a base type from a method declared to return a specific subclass. Such a downcast is now a type mismatch. Code that proves the narrower type first, with instanceof or a match on the value's class, keeps resolving to it and stays quiet.
  • An Eloquent query resolves to the model's own collection class. Post::where(...)->get(), a relation property, and $post->comments()->get() all resolve to the collection the model actually builds (from #[CollectedBy], @use HasCollection<...>, or a newCollection() override) rather than the base Illuminate\Database\Eloquent\Collection. The collection's own methods complete on the result, hover names the real class, and a method declared to return it type-checks. A self-referential relation (@return HasMany<self, $this>) and a collection class declared in the model's own namespace are both recognized, and a query for a different model resolves to that model's collection.
  • view('name') resolves to the concrete view object. The helper's declared return type is the Illuminate\Contracts\View\View contract, but Laravel's view factory always builds an Illuminate\View\View. Every Blade component's render(): View signature names the concrete class, so the contract-typed result reported a mismatch on correct code. view() with a template name now resolves to the concrete class; with no arguments it is still the view factory.
  • match ($value::class) narrows its subject in each arm. A dispatch table written as match ($node::class) { Foo::class, Bar::class => $this->handle($node), ... } left $node at its declared type, so passing it to a handler typed for the arm's class was reported as a mismatch and completion on it offered the wrong members. Each arm now narrows the subject to the classes it names.
  • An override inherits the @param types of the method it implements. PHP requires an override to restate every native type hint, so an implementation of a generic interface method (processNode(Node $node) under @implements Rule<CallLike>) still receives the narrower type the interface's @param describes. That type is now used inside the method body. An override that deliberately names a different type keeps its own.
  • Template parameters bind from a property argument. @template T of Base bound through @param array<string, T> $items fell back to T = Base when the argument was a property ($this->items) rather than a local variable, widening every result derived from it. The property's declared element type is now used, including when the template sits inside a nested generic such as array<string, array<string, T>>.
  • A class constant reference keeps its declared literal value. Foo::STRING_CONSTANT resolved to the widened base type (string), losing the precision the initializer declares, while the equivalent literal assignment already kept it. Constants with a scalar initializer now resolve to that value ('foo', 1, 3.14) wherever the constant is referenced, including through self::, inheritance, and global const/define() constants, so hover shows the value and match/comparison narrowing can use it.
  • Scalar literals stay precise until PHP requires a broader type. Assignments now retain exact string, integer, and float values through parentheses, unary signs, match expressions, ternaries, and null coalescing, so hover, diagnostics, and inferred PHPDoc return types can use literal unions without reconstructing them in each consumer. Mutable array/list storage widens scalar values at the collection boundary, PHP array-key coercions are reflected in generic key types, and operations that change a value (such as increment/decrement, string-offset writes, arithmetic, and object casts) invalidate only the precision they actually change. Contributed by @snowyukitty.
  • A ternary with a statically-known condition no longer unions in its dead arm. true ? 1 : 2 resolved to 1|2 instead of 1, and false ? 1 : 2 resolved to 1|2 instead of 2, because both branches were always combined regardless of whether the condition could ever take both paths. A condition that is a bare literal (true, false, null, a nonzero/zero number, or a non-empty/empty string) now resolves only the reachable branch, matching PHPStan.
  • A slash in a Route::resource() name no longer produces the wrong route names. Route::resource('photos/comments', …) was read as the nested resource photos.comments, but Laravel treats the slash as a URI prefix and registers the resource comments, so completion and go-to-definition offered names (photos.comments.show) that the application does not have. The names are now comments.show and the URI photos/comments/{comment}. Contributed by @shuvroroy (#308).
  • The contents of a Blade {{-- ... --}} comment no longer desync the rest of the file. An apostrophe or double quote anywhere inside a Blade comment was mistaken for the start of a PHP string literal, so the preprocessor skipped past the comment's actual --}} terminator hunting for a matching closing quote, corrupting everything after it and producing bogus "undefined variable" and "unexpected token" errors far below the comment. Commenting out an echo ({{-- {{ $old }} --}}) and mentioning @endphp in comment prose had the same effect, and an unterminated comment no longer swallows the whole file. Comment text is now treated as text, so only a --}} ends a comment. Contributed by @krist7599555 (#303).
  • Parameter-name inlay hints in Blade templates are no longer filtered against the wrong positions. Deciding which hints fall inside the visible part of the file compared each argument's position in the compiled PHP against a viewport still measured in Blade coordinates, so hints could go missing or appear for arguments that were scrolled out of view. The already-translated viewport is now used.
  • static/$this return types are no longer flagged when passed where a Stringable object is accepted. Passing a value typed static(Foo) or $this(Foo) to a string parameter reported a type mismatch even when Foo implements Stringable, which PHP accepts by calling __toString(). This hit any use of SimpleXMLElement, whose magic __get returns static, so code like (string) $xml->Body->Message reported a false positive on every argument passed to a string parameter.
  • Inline {@see} references nested inside other docblock text are now found. An inline {@see Foo} written in the description of another tag (@param Type $x see {@see Foo}), or nested inside another inline tag ({@deprecated use {@see Bar} instead}), was invisible to go-to-definition, find references, and rename: the previous scan located a reference by searching for the next } after {@see, which stopped at the first brace it met rather than the one that actually closed the tag. Inline {@see} references are now read from the same PHPDoc parse tree as everything else in the docblock.
  • Docblock navigation works in @method and @property tags written across several lines. A tag whose type wrapped onto a continuation line, such as a @method Collection<int, Item> fetchAll(Filter $filter) broken after the <, only ever had its first line read. Everything after it was invisible: go-to-definition, find references, and rename did nothing on the method or property name, on the type arguments, or on the parameter types, and the truncated first line was reported as a class named Collection< that resolved to nothing. Docblock positions now come from the PHPDoc grammar itself, so every name in such a tag is navigable wherever it sits.
  • Docblock navigation lands on the right name in types that mix a * wildcard with a non-ASCII name. In a type such as @return Map<Café, *, User>, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking User resolved nothing (or the wrong symbol). The PHPStan * wildcard is now read directly by the type grammar rather than rewritten to mixed beforehand, which removes the byte-offset bookkeeping that was corrupting the positions.
  • Formatting a short method chain starting with new X(...) no longer breaks it across lines unnecessarily. When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like (new Foo(...))->bar() onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0.
  • @method and @property tags on an implemented interface are now always applied. A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents.
  • Find References, Rename, and Go to Implementation no longer look stalled during startup indexing. A search started while the background index is still parsing the workspace waits for that index to finish, since acting on a partial index would silently miss results. That wait now shows in the request's own progress bar as "Waiting for workspace index" alongside the index's live file counts, instead of sitting at "Resolving…" with no indication of what it is waiting for.
  • Type narrowing against @phpstan-assert/@psalm-assert no longer leaks memory. Evaluating a narrowing call such as Assert::isInstanceOf($x, Foo::class) or a custom function/method with the same annotations allocated a small amount of memory that was never freed. This ran on every conditional touched during completion, hover, diagnostics, and go-to-definition, so memory held by a long-running editor session grew slowly but permanently the more the project was edited. Fixed by no longer leaking the allocation.
  • Renaming a namespace no longer corrupts group use statements. Renaming a namespace segment that is imported with a group use (e.g. use App\Old\{Foo, Bar};) previously rewrote the group's shared prefix and then also spliced the new prefix into each member name, producing invalid PHP like use App\New\{App\New\Foo, Bar};. The member names are left untouched now, since the prefix rewrite alone already updates the whole statement correctly.
  • Parameter name inlay hints no longer shift to the wrong parameter when only part of a multi-line call is visible. Editors request inlay hints only for the currently visible viewport, and when a call's arguments were split across the viewport boundary, every hint after the first excluded argument was labelled with the previous parameter's name instead of its own. This was most noticeable on multi-line constructor calls with several arguments, such as those using constructor property promotion.
  • Rename no longer rewrites unrelated code when a file has just been edited. PHPantom re-reads a file in the background after every keystroke, and rename could hand the editor positions measured against the previous version of that file, so confirming the rename edited whatever now sat at those positions. Rename and the rename preview now check that every position they report still points at the symbol, in each file the rename touches, and offer nothing at all when one does not. Retrying a moment later, once the background re-read has caught up, renames normally.
  • Diagnostic and request workers no longer copy the embedded stub indexes. Every diagnostic pass and every hover, completion, or go-to-definition request cloned the full embedded stub class, function, and constant indexes (thousands of entries each) instead of sharing them. During workspace analysis this happened twice per file, and the resulting allocation churn serialised the parallel diagnostic workers in the memory allocator. The indexes are now shared, roughly halving analyze wall time on large Laravel projects and removing the same overhead from every editor request.
  • Member resolution no longer degrades on deeply nested class dependencies. Class resolution previously cut off at internal nesting limits, silently returning incomplete member sets (missing completions, spurious unknown-member diagnostics) on projects with deeply intertwined hierarchies and virtual members. Those limits are gone: only genuine dependency cycles, such as two Eloquent models whose relationships reference each other, fall back to a partial view, and they now do so deterministically instead of depending on which class happened to resolve first on a given thread.
  • Completion works after closing a multi-line closure argument on the same line as the next chain operator. Typing -> immediately after a call like ->map(function (...) { ... })-> now resolves the full receiver instead of returning no member suggestions until the operator is moved to a new line. Contributed by @calebdw.
  • Conditional return types recognize interpolated strings as strings. A function with a conditional return type like ($key is string ? mixed : null) now correctly resolves the string branch when called with an interpolated string argument (e.g. config("{$prefix}.host")). Previously the interpolated string was not recognized as a string literal, causing the return type to fall through to the else branch and resolve as null. Contributed by @calebdw.
  • Renaming a constructor-promoted property parameter now cascades to $this->prop usages. Renaming private int $someField in a constructor's parameter list previously only updated the parameter declaration itself, leaving every $this->someField reference elsewhere in the class stale. Rename, find references, document highlight, and linked editing now treat a promoted property parameter the same as an ordinary property declaration.
  • Closure/arrow-function parameters passed after reordering named arguments now infer correctly. When a call used named arguments to reorder or skip parameters ahead of a closure argument (e.g. process(class: Product::class, cb: function ($p) {...}, flag: true)), the closure's own parameter type stopped being inferred from the target function/method's callable(...)/Closure(...) signature, silently losing completions and hover for the closure's parameters. Argument-to-parameter binding now follows PHP's actual named-argument rules instead of assuming call position matches declared position.
  • Deprecated enum cases are recognized. Enum cases annotated with @deprecated or #[Deprecated] now carry deprecation metadata, so usages like self::Low can be highlighted as deprecated in contextual semantic-token mode. Contributed by @calebdw.
  • Class constant accesses use constant semantic highlighting. self::CONSTANT, static::CONSTANT, parent::CONSTANT, and ClassName::CONSTANT now emit the enumMember semantic token instead of being colored as properties, while ClassName::$property still emits property. Contributed by @calebdw.
  • PHP attributes use decorator semantic highlighting. Attribute class names in #[...] now emit the decorator semantic token instead of class, so editor themes can color attributes differently from normal class references. Contributed by @calebdw.
  • Go-to-definition on overriding methods jumps to the parent declaration. When the cursor is on a method definition that overrides a parent or implements an interface method, go-to-definition now navigates to the prototype declaration instead of returning call-site references. Similarly, go-to-definition on a class name jumps to the parent class when one exists. Methods and classes that don't override anything still show usages as before. Contributed by @calebdw.
  • Go-to-definition on overridden properties and constants jumps to the prototype declaration. Declaration-site navigation now follows overridden properties and constants to the nearest parent or trait declaration, matching the override behavior for methods. Contributed by @calebdw.
  • Code lens navigation follows what the editor says it can do. Clicking a code lens annotation (e.g. "overrides Parent::method") opens the declaration it points at, in every editor that shows lenses. LSP has no standard command for "go to this location", so the lens is now shaped from the capabilities the editor announces when it connects: one that handles window/showDocument is asked to open the file, and one that does not is handed a lens it can act on by itself. Editors are no longer recognised by name, which is what used to decide it. Contributed by @calebdw.
  • Go-to-implementation works when interface and class share the same short name. An interface and its implementing class in different namespaces but with the same class name (e.g. App\Contracts\HttpClient and App\Foo\HttpClient) now resolves correctly instead of returning no results. Contributed by @calebdw.
  • Static method calls resolve return types as accurately as instance calls. Foo::bar() previously missed inference that $foo->bar() already had: return types behind a @phpstan-type alias, inherited return types substituted through a generic interface or trait, and the __callStatic() magic-method fallback. These now resolve the same way for both call styles.
  • Laravel facade static calls keep concrete method return types. Static calls on Laravel-style facades now resolve missing methods through getFacadeAccessor() and facade @mixin targets before falling back to __callStatic(), so values like Driver::details() keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw.
  • class-string<static> parameters no longer reject sibling subclass constants. Static helper calls from a shared base class that pass concrete ::class constants for sibling subclasses no longer report false argument-type mismatches against class-string<static>. Contributed by @calebdw.
  • class-string<static> parameters now diagnose provably invalid class strings. Passing an unrelated class to a class-string<static> parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves static to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. static and $this now carry the class they are bound to instead of being flattened to a bare class name, so a value that is "at least this class, possibly a subclass" is no longer mistaken for the class itself. The binding is kept only where PHP actually keeps it open, which is a call that forwards it: $this->, self::, static::, and parent::. Writing the class out pins it, so A::create() on a @return static method resolves to exactly A, and so do new A, a variable declared A, and a ::class string. parent::create() binds to the calling class rather than the parent, @return self stays on the class that declares it, and a first-class callable ($this->create(...)()) resolves the same as the direct call it stands for. Hover shows the difference: where the runtime class is still open the type reads as static(App\Foo) (or $this(App\Foo) for the exact instance), the same notation PHPStan reports, and where it is pinned it reads as the class alone. Contributed by @calebdw.
  • Built-in PHP classes shadowed by vendor polyfills resolve to the real definition. When an installed package ships a polyfill for a PHP built-in (for example symfony/polyfill-php84's RoundingMode), resolution sometimes picked the polyfill's legacy pre-enum declaration instead of the built-in, turning enum cases into plain int constants and reporting false "expects RoundingMode, got int" argument mismatches. Which declaration won could change from one run to the next, making whole-project analysis results nondeterministic. Global names of built-in classes now always resolve to the bundled PHP definition, and classes discovered inside phar archives are indexed in a stable order.
  • Member name positions no longer suggest classes. Typing a name after function, const, or enum case (for example protected function getC) no longer offers unrelated class names from the project. Property names were already safe because they start with $. Contributed by @calebdw.
  • Null-initialized variables reassigned in an untyped foreach are not stuck as null. When the iterable has no known element type (for example an untyped parameter), the loop value is now treated as mixed, so $x = $value after $x = null participates in post-loop merge and is_null early-return narrowing instead of leaving a false null type at later call sites. Contributed by @calebdw.
  • By-reference method out-parameters no longer flag undefined variables. Passing an undeclared variable into a by-ref parameter (e.g. new A()->dosmth($y, $foo) where $foo is &$x) is valid PHP and now defines the variable for later use, matching free functions like preg_match(..., $matches) and $this->method($out). Contributed by @calebdw.
  • Eloquent query chains keep the concrete model through Query mixin fluents. Methods that only exist on Query\Builder (for example lockForUpdate()) and are reached via @mixin no longer drop Builder<TModel> before firstOrFail() / first(), so the result types as the model instead of Model|stdClass. Classes that use Laravel's ForwardsCalls trait apply decorated-forward return semantics on mixed-in methods: self-like or mixin-class returns become the forwarder's $this (preserving generics), while non-self returns pass through. The same path covers relationship chains such as $this->posts()->lockForUpdate()->firstOrFail(). Contributed by @calebdw.
  • Eloquent type inference works for aliased models. Builder and where{Property}() virtual methods now substitute TModel with the model's fully-qualified name instead of its short name, so use App\Models\Channel as ChannelModel next to another Channel import still types ChannelModel::whereName(...)->firstOrFail() as App\Models\Channel. Contributed by @calebdw.
  • Linux binaries run on any distribution. The Linux language server is now a statically linked build with no minimum glibc version, so it starts on old-glibc distributions (RHEL/CentOS 8, Debian 11) and musl-based ones (Alpine) where the previous build failed with GLIBC_… not found or a missing dynamic loader. Whole-project analysis is slightly faster than the previous Linux build, and idle memory usage is lower on many-core machines.
  • Deeply nested functional-style code no longer crashes the analyzer. Nested calls that pass closures or arrow functions to array_map, array_filter, and similar (for example an array_filter(array_map(fn(...) => ..., array_filter(...)), fn(...) => ...) chain) previously overflowed the stack and aborted the language server. Such expressions now analyse to completion.
  • Editing a file while the workspace is indexing no longer shows stale results. A file you opened and edited during the background index (or during the index a Find References triggers) kept its hover, diagnostics, and references computed from the pre-edit version on disk until the next keystroke. Open buffers now keep their live edited state throughout indexing.
  • Custom Blade view directories are recognized. Projects that register non-default view paths in config/view.php (for example a resources/backoffice/views directory) no longer see valid view() names flagged as unknown, and go-to-definition and hover resolve those templates to the file under the configured root. View names are discovered by scanning the configured directories on disk, so templates are found even before they're opened.
  • Several Blade directives no longer produce cascading false-positive diagnostics. @class, @style, @checked, @selected, @disabled, @readonly, @required, and @stack previously corrupted everything after them in a template when used inline (for example <div @class(['active' => $isActive])>), reporting dozens of unrelated syntax errors for the rest of the file. $errors and $__env are now visible everywhere in a template instead of only sometimes. @unless, @isset, and @empty(...) no longer leave a dangling parenthesis that broke every diagnostic after them. A literal <?xml ... ?> declaration in a template (e.g. an RSS or sitemap feed) is no longer misread as a PHP tag. @json($var) and @dump($var) are now recognized directives, so a variable used only inside one of them is no longer flagged as unused. @use(...) and @inject(...) no longer swallow the rest of the template either: @use('App\Models\Post') now imports the class so its short name resolves in the template (aliases, the two-argument alias form, grouped imports, and the function/const modifiers are handled), and @inject('svc', 'App\Service') defines the injected variable so it resolves and is not reported as undefined.
  • Blade component bound attributes are analysed as PHP. The expression in a bound attribute such as :src="../$image", :key="$item->id", or the :$message shorthand is now understood as real PHP. A variable used only in a bound attribute is no longer flagged as unused, and hover, go-to-definition, and completion work inside the expression. Colons that are not bindings (an attribute value like href="mailto:x", a 10:30 in text, or an escaped ::class) are left untouched.
  • PHPStan diagnostics no longer report false positives from location-aware rules while you edit. Rules that depend on where a file lives (for example Larastan flagging env() calls outside the config/ directory) previously fired on every matching call because the unsaved buffer was analysed from a scratch location. PHPantom now analyses the file at its real path when the buffer matches what's on disk, and otherwise substitutes the buffer in place, so these rules see the file's true location and agree with a plain command-line PHPStan run.
  • Eloquent models always expose their primary key. A model whose table has no migration and no schema dump no longer reports a false Property 'id' not found on $model->id. The primary key is synthesized for every Eloquent model, honouring $primaryKey for the column name and $keyType for the type (int by default, string for UUID or ULID keys).
  • Laravel virtual property hover and navigation prefer backing accessors. Hover labels for accessor and computed Eloquent properties now describe the source without echoing the backing method name, and go-to-definition on accessor/computed properties prefers the backing accessor or legacy mutator before falling back to $appends or other Eloquent metadata arrays. Legacy setXAttribute mutators are shown alongside database, cast, attribute-default, accessor, or computed-property sources when they exist. Contributed by @calebdw.
  • Laravel macro callbacks registered through facades now infer $this as the concrete facade target. $this inside callbacks such as Request::macro('shouldReturnJson', function () { ... }) and Context::macro(...) now resolves to the class behind the facade instead of the surrounding service provider. Contributed by @calebdw.
  • self:: and static:: inside Laravel macro callbacks resolve to the macro target. In a closure passed to a macro() registration (Laravel Macroable or Carbon), self:: and static:: now resolve to the class the macro is registered on instead of the service provider that lexically encloses the registration, matching how the closure is bound at runtime. Carbon's static macro idiom self::this()->... no longer reports a false unknown-method diagnostic, and completion after self:: inside the callback offers the target's members.
  • Find References and Rename no longer match unrelated same-named methods when a call's receiver type can't be resolved. A call like $x->find() used to conservatively match every find() method in the project when $x's type couldn't be determined, drowning results in unrelated matches for common method names. Find References on an interface method now also includes every implementing class; Rename Symbol stays scoped to the single concrete implementation being renamed, so it never rewrites unrelated same-named methods elsewhere. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/186.
  • Go-to-implementation and type hierarchy return the same results every time. After the workspace finished indexing, these features listed only your project's implementing classes, but a class from a dependency could slip in if you happened to have viewed it earlier in the session, so the same query gave different results depending on what you'd looked at. Results are now consistently limited to your own code, matching what the workspace index actually covers.
  • Blade files with raw <?php ... ?> tags no longer report false syntax errors. PHP code embedded directly in a Blade template (outside @php/@endphp) is now recognized and passed through unmodified, so string literals that happen to start with @ (e.g. a JSON-LD '@context' array key) are no longer misread as Blade directives.
  • @switch/@case with a class-constant case value no longer reports a syntax error. @case (Some\Namespaced\Enum::VALUE) now translates to a valid case arm instead of silently corrupting the rest of the file.
  • Generated return types and @return tags understand every kind of return expression, not just literals and variables. Inferring a missing return type now resolves method/function calls, ternaries, matches, property access, and array literals split across multiple lines through the same type engine as hover, instead of degrading to mixed (or, for multi-line array literals, to a coarser array<mixed>) for anything beyond a simple literal, new, or a plain variable.
  • Calls to functions declared in another namespace block of the same file resolve their return type. In a file that declares more than one namespace, a call to a function from a later block used to leave the returned value untyped unless the function carried an @return docblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type.
  • @template bindings resolve correctly when a call uses named arguments. A generic function or method called with named arguments (for example process(class: Product::class, flag: true, function ($p) { ... })) previously bound its template parameter from the wrong argument whenever the named arguments were out of declaration order, leaving closure parameters untyped. Named arguments now route to the parameter they actually target.
  • Vendor-provided functions and constants no longer rank as project-native in completion. With the self, full, and composer indexing strategies, functions and constants discovered while scanning vendor packages lost their package origin when merged into the workspace scan, so they sorted ahead of other vendor symbols as if declared in the project itself instead of by their actual dependency tier.
  • Argument-count and argument-type diagnostics no longer mix up calls that share the same text but resolve differently. A per-file cache reused the first resolved target for every call site with the same expression text, without accounting for the fact that self::method(), static::method(), and parent::method() resolve differently depending on which class they appear in, and $var->method() resolves differently depending on what type $var holds at that call site. Two classes each declaring their own self::make() with a different required argument count, or two methods each assigning a different type to a same-named variable before calling the same method name on it, could silently report the wrong argument-count or argument-type diagnostic (or miss one) on every call after the first. These forms are now always resolved fresh per call site.
  • A value proven to be two types at once is one value, not a choice between them. if ($x instanceof Reader && $x instanceof Writer) states that both hold, and assert($x instanceof MockInterface) on a value already typed as a concrete class states the same thing about a mock. Both cases kept the two classes side by side but described them as Reader|Writer, so passing the value somewhere that accepts just one of them was measured against the other as well and reported as a type error ("Writer does not satisfy Reader"). Such a value is now described as the intersection it actually has, on locals and on property paths alike, so it satisfies either member on its own while completion and hover keep offering members from both. An || check still proves only that the value is one of its alternatives, so passing it where a single one is required is reported as before.
  • A full ternary's own condition now narrows its then branch. $x ? $x : '' is the natural way to write $x ?: '' when a linter or a habit favours the explicit form, but only the short form ruled out $x's falsy members: the full ternary's then branch resolved $x against its un-narrowed type, so useString($x ? $x : '') on a string|false was reported as a type error inside the branch written to prevent it. The then branch is now narrowed the same way an if ($x) { … } body already was, for the same guards: a bare truthy check, $x !== null, isset($x), !empty($x), and the T|false idiom's $x !== false.
  • Echoing a translation in a Blade template is no longer reported as printing an array. {{ __('messages.welcome') }} is the single most common line in a localised template, and every one of them was reported: Blade compiles the echo to e($value), which takes a scalar, while __()/trans()/Lang::get() are declared array|string because a translation key may name a whole group of strings. A literal key is resolved against the project's lang/ files (and package translation files, for a namespaced key), so a key that names a single string entry now narrows the call's return type to string; a key that names a group, or that cannot be resolved, keeps the full union.
  • A for loop's condition narrows its body the same way an if or a while's does. for (; ($row = fgetcsv($handle)) !== false; ) and for (; $line !== false; $line = readLine()) are the two shapes a read loop is written in when a foreach won't do, and neither ruled anything out: the body saw the whole array|false or string|false the read produced, so passing the value on to anything that takes the non-sentinel type was reported on the line the condition exists to protect. for's condition clause is now narrowed exactly the way while's is, and the inverse narrowing applies to the scope after the loop, so every guard form a while already understands (!== false, !== null, instanceof, isset, a @phpstan-assert-if-true predicate) works the same way in a for.
  • A @method tag's own inline template parameter is no longer read as a class name. @method TVal get<TVal of mixed>(TVal $default) declares TVal inline, between the method name and its parameter list, and that declaration went unread: both uses of TVal, in the return type and in the parameter type, were treated as class references and reported as unknown classes. The inline template is now registered the same way a @template tag is, scoped to that one @method tag so a different @method tag in the same docblock can reuse the name for something unrelated.
  • A scalar check on an argument-less method call narrows the call the same way it narrows a property. if ($this->value() !== false) { useString($this->value()); } on a method declared string|false reported the call as still string|false inside the branch that had just ruled false out, even though the equivalent check on a property of the same type narrowed correctly. The call was never seeded as a narrowing subject when its return type resolved to no class, on the assumption that only a template parameter or a generic alias would do that, but a concrete scalar union like string|false resolves to no class for the same reason and was caught by the same rule. A call whose declared return type is built entirely from scalar and pseudo-types is now seeded, since those can never be a template parameter or alias, so the same narrowing that already works for a property applies to it too.
  • A false check narrows its else branch too, not just the guard clause that returns. if ($value === false) { … } else { useString($value); } left $value as string|false in the else, and so did !empty($value), which rules out false along with null but was only ever stripping null. Both directions of the equivalent null check already worked, so the gap was specific to false. The || guard clause's implicit else (if ($value === false || rand(0, 1)) { return; }) fell out of the same fix once it was decomposed operand by operand the way the equivalent &&-chain narrowing already is, which also fixes the same || shape for a plain null check.
  • A return type that depends on an argument's value is read from the value passed, or from the argument's default when it is left out. A conditional @return keyed on a value (($format is 0 ? int : list<string>)) only ever recognised a quoted string, so an int literal decided nothing and an omitted argument decided nothing either, leaving every call to read back the union of every branch and any use of the result in a typed position reported. The literal at the call site now decides the branch, compared by value rather than by spelling, and an omitted argument is decided by the default it declares, since that is the value it takes at runtime. str_word_count() is the standard library's example of the shape and now resolves to the count, the word list, or the offset-keyed map according to its $format, so returning it from an int method is no longer reported. A negated condition is honoured in both directions, and a value the call site cannot pin down still reads back everything the call could return.
  • A tag written on a docblock's opening line is read. A docblock that starts its first tag on the /** line itself, as in /** @param 'a'|'b' $key followed by more tags below, had that first tag ignored for @param and @var. The same tag moved down a line worked, and so did the fully single-line spelling, so only the shape that shares the opening line was affected. The parameter or variable fell back to its native hint, which is wider than what was declared, so narrowing, argument checks, and hover all read the wide type, and a @return further down the same docblock (which was read) could then be reported as incompatible with the body's widened value.
  • ?-> on a null subject is no longer reported as a crash. echo $customer?->id; where $customer is null was reported as "Cannot access property 'id' on type 'null'", the same diagnostic a plain -> earns for the same subject. The nullsafe operator exists precisely so that case doesn't crash: it short-circuits to null without touching the property. The diagnostic now tells the two operators apart and only reports a null subject under a plain ->, where accessing it is still a real crash.
  • A replace on a string comes back a string, and one on an array comes back an array. preg_replace(), preg_replace_callback(), preg_filter(), str_replace(), str_ireplace() and substr_replace() return whatever shape their subject was, but their signatures can only name the flat union of both overloads, so every call was read as array|string no matter what it was handed. Passing the result of a replace on a plain string straight into a string parameter or returning it from a string function was reported for an array branch the call could never take, which was the single largest source of argument and return mismatches in real code. Each of them now resolves against the subject at the call site: a string subject rules the array branch out, an array subject rules the string branch out and keeps the keys it was given, and a subject whose shape is genuinely unknown still reports both, since that is all the call can promise. preg_replace()'s null error result survives for a string subject, where PHP really can return it, and is dropped for an array subject, where it cannot.
  • json_encode() with JSON_THROW_ON_ERROR can no longer be false. The flag is how modern code asks for a JsonException instead of a silent false, and the declared string|false return type has no way to say so, so every such call was still read as possibly false: handing the result to a string parameter, returning it, or concatenating it were all reported for a branch the flag had already ruled out. The flag is now read at the call site, whether it is passed on its own, OR-ed together with other JSON flags such as JSON_PRETTY_PRINT, written as a plain number, or held in a constant. A call that leaves the flag out, or whose flags cannot be read, keeps the failure branch, because there it is real.
  • A cast argument is read as the type it casts to. (string) $customer->mobile is a string whatever the property holds, but an argument written that way resolved to nothing at all, so anything that reads a call's arguments to work out its return type was left guessing: a @template bound from that argument stayed unbound, and a return type that depends on the argument fell back to naming every branch at once. Every cast now answers with what it produces, so str_replace('a', 'b', (string) $value) is a string and not the string-or-array union its signature also allows.
  • A parameter default written self::SOME_CONST resolves against the class that declares it, not the caller's. An omitted argument is decided by its parameter's declared default, and when that default names a class constant through self::, static::, or parent::, the keyword was resolved against the class the call sits in rather than the class that wrote the default. ContainerInterface::get(string $id, int $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE) looked the constant up on whatever class happened to call get(), found nothing there, and left the conditional return type undecided, so $container->get(Service::class) reported Service|object|null instead of the branch the default actually selects. The keyword now resolves against the method's own declaring class, which is where it was written.
  • A class named through a whole-namespace import is the same class as its fully-qualified name. Importing a namespace once (use App\Support;) and then writing Support\Pen wherever the class is wanted is a common alternative to one use per class, and PHPantom kept that spelling as a type of its own rather than resolving it to App\Support\Pen. A generic bound from two arguments that named the class both ways bound the union of the two spellings instead of the one class, so a closure whose parameter and return type were written that way was reported as not satisfying the callable(...) it was passed to. A ::class written that way lost its namespace entirely once it travelled to a factory in another file, which then either failed to resolve the class or, worse, silently found an unrelated class of the same short name in the factory's own namespace and reported every member read off the result. All of these now resolve to the class the name actually refers to.
  • @param-closure-this resolves its class from the file that declares it. The tag names the class a callback's $this is bound to, and PHPantom looked that name up in the calling file's imports instead of the declaring method's. A caller never spells the class itself, since only the tag mentions it, so unless the caller happened to import it too the tag resolved to nothing, $this silently fell back to the enclosing class, and every member read inside the callback was reported as unknown. The tag is now resolved where it is written, the same as every other docblock type on the method.
  • A builder method chained on an Eloquent relation stays on the relation. $this->belongsTo(Author::class)->withTrashed() was inferred as returning Builder<Author>, so a method declared : BelongsTo (the standard Laravel signature) got a false type_mismatch_return, and on a large Laravel codebase this one pattern accounted for most of them. At runtime a relation forwards the call to its query builder and hands back the relation whenever the builder came back, so the chain never leaves the relation. Scope methods, @method virtual methods (such as withTrashed from SoftDeletes), and where{Column}() methods reached through a relation now carry the relation as their return type, including when the model uses a custom builder subclass, and the rest of the chain resolves off it as before. Closes #354.
  • A float reaching an int position outside declare(strict_types=1) is no longer reported. int / int resolves to int|float, which is correct, but a file with no strict_types declaration coerces the float half of that union on the way in rather than raising a TypeError, so $this->timeout = $max / 300 against a typed int $timeout property, and return $length / 86400; from a function declared : int, were both reported for a branch PHP itself accepts. A float argument, return value, or property assignment is now accepted the same way numeric and string already are outside strict_types. Under declare(strict_types=1) PHP really does refuse a float, but it also keeps int / int an int unless the division does not come out even, which is a property of the two values rather than of their types, so no annotation on the operands could rule the float out and the only way to quiet the report was a cast that changes what the code does. The union the operator produces is now treated the way the standard library's unchecked failure branches already are, so one branch fitting the target is enough. The type is unchanged everywhere else: hover still reads int|float, and is_float() still narrows it. This is the operator's own union and not unions at large, so a declared int|float still has to fit an int whole.
  • int ** int carries the same benevolent int|float union as division. PHP promotes exponentiation to a float on overflow (2 ** 64) or a negative exponent (2 ** -1), a property of the operand values rather than their types, so takes_int($base ** $exp) and $base **= $exp under declare(strict_types=1) were reported the same way plain division was before the fix above. The result is now treated with the same benevolence: one branch fitting the target is enough, and a declared int|float or an exponentiation of one still has to fit whole.
  • A reopened file no longer shows diagnostics from before it was closed. Each open file's pull resultId counted up from zero, and closing a file dropped that counter along with everything else, so reopening it started counting from zero again while the editor could still be holding a higher id from before the close. Once the reopened file's recomputes climbed back to that number, a pull carrying the stale id matched the current one and the editor kept showing the diagnostics the file had before it was closed. Result ids are now drawn from a single sequence for the whole session, the way workspace/diagnostic's ids already were, so a reopened file can never land on an id the editor saw before.
  • PHPStan auto-detection no longer picks up a vendor/bin/phpstan the project doesn't actually depend on. A phpstan binary from a transitive dependency of something else, was proxied as though the project itself used PHPStan. Auto-detection under vendor/bin now only fires when composer.json requires phpstan/phpstan directly, in require or require-dev. A phpstan found on $PATH is unaffected, since installing it globally is a deliberate choice, and an explicit command in .phpantom.toml still overrides detection entirely, which is how a manually managed install (a versioned .phar outside the Composer bin dir, as OpenCart ships one) is wired up.
  • A project that uses Mago only for formatting no longer gets Mago's lint and analyze reports. Any mago.toml at the workspace root was treated as a request for every Mago diagnostic, so a project that had written one to pick a formatting style, which is what PHPantom's own documentation suggests for controlling the built-in formatter, suddenly saw a wall of problems from a checker it never ran. Which of Mago's checkers run is now decided by what the project configures: a [linter] table turns on mago lint, an [analyzer] table turns on mago analyze, and a file that carries neither turns on neither. lint and analyze under [mago] in .phpantom.toml override the detection in either direction, so a project that runs a checker without configuring it can still ask for the reports.
  • mago analyze no longer reports Laravel code it has no way to understand. Mago's analyser has no built-in Laravel support, so it cannot follow the Eloquent and facade indirection PHPantom models; the gap is meant to be closed by an extension, a mechanism Mago only grew in 1.47, and no Laravel extension exists yet. On a Laravel project, mago analyze is therefore proxied only when the mago.toml wires one up, either an enabled [extension-hosts.*] entry or a namespaced plugin such as plugins = ["acme/laravel"]. Mago's own plugins (stdlib, psl, flow-php, psr-container) do not count, since none of them carries that knowledge. mago lint keeps running, as its linter does have a Laravel integration, and analyze under [mago] still forces the issue either way.
  • Mago auto-detection no longer picks up a vendor/bin/mago the project doesn't actually depend on. As with PHPStan above, a binary installed as somebody else's transitive dependency was proxied as though the project itself used Mago. Auto-detection under vendor/bin now only fires when composer.json requires carthage-software/mago directly, and a global install or an explicit command is unaffected.
  • non-falsy-string is accepted where non-empty-string is expected. non-falsy-string (and its Psalm synonym truthy-string) excludes both "" and "0", so it is strictly narrower than non-empty-string, which excludes only "", but passing one where the other was wanted was reported as a type mismatch. The three names now form the relation they actually have: non-falsy-string and truthy-string are synonyms of each other, and both are subtypes of non-empty-string.
  • A method declared @return mixed reads its real return type off its body. mixed is as uninformative as no return type at all, since every type satisfies it, but a method that declared it was treated as fully resolved and never read further, so (new Registry())->fetch($w) stayed mixed even though its body plainly returned a Widget. Such a method now falls through to the same body-return inference an undeclared return type already gets, hover and completion see the concrete type, and mixed is still what remains once inference truly has nothing better to offer. A bare @template parameter without a bound already erases to mixed for a class that uses a generic trait without pinning it down; body inference now leaves that case alone too, since re-reading the trait's own source can only recover the parameter's own unbound name, which is no more informative than mixed was.
  • A Laravel application PHPStan would misread is left alone whichever binary PHPStan comes from. PHPantom refuses to auto-run plain PHPStan at a Laravel application that has not installed Larastan, because it misreads Eloquent, the facades, and the container and reports correct code as broken. That refusal covered the project's own vendor/bin/phpstan but not a phpstan on $PATH, so a developer with a global install got exactly the wall of false positives the rule exists to prevent, on every save, from a binary that additionally lacks the project's own extensions. The refusal now applies to every binary, and a phpstan.neon still lifts it: a project that hand-authors a config has said what it wants. A library that merely requires an Illuminate component is no longer treated as an application in this check, since it has no framework to misread, so its own direct phpstan/phpstan dependency is honoured again.
  • A phpstan.dist.neon certifies PHPStan the way the other two spellings do. PHPStan reads three config-file names, and PHPantom accepted all three when deciding whether to run a project-wide analysis but only two when deciding whether vendor/bin/phpstan may be used. A project using the third spelling, with PHPStan as a transitive dependency, was cleared for the whole-project run and then refused the binary to do it with. Both questions now read one list.
  • .phpantom.toml autocomplete knows the Mago lint and analyze keys. Both keys work and are documented, but config-schema.json still listed only command and the timeouts under [mago], so an editor offered no completion for them and could flag them as unknown.
  • The documented macOS path for the global config is the one that is read. ~/Library/Application Support/phpantom_lsp/.phpantom.toml was documented, but the config is read from ~/.config/phpantom_lsp/.phpantom.toml on macOS as well as Linux, so a macOS user who hand-created the documented file got one that was silently ignored. The documentation now names the path in use; the XDG location stays, since it is where a command-line tool's config is expected and moving it would strand every existing user's file.
  • A misspelled variable inside a property hook is reported. Undefined-variable checking walked method bodies only, so a typo inside a get or set hook was never flagged, in either spelling of the body and including a hook on a constructor-promoted property. A hook body is now checked like any other body: it is its own scope, a set hook's implicit $value is defined without being declared, and the isset(), compact(), and extract() rules that apply everywhere else apply inside it. The code actions that work on the scope around the cursor (extract function, extract variable, inline variable) now see the hook's own scope too, rather than falling back to the file's top level.
  • Members are checked inside a closure written in a one-line property hook. A closure embedded in the expression body of a hook, as in get => array_map(fn (Part $p) => $p->format(), $this->parts);, never had its parameters typed, so every member access on them was skipped instead of checked. The closure now gets its own scope, so a call on a parameter that does not exist is reported the way it is anywhere else.
  • A custom Eloquent builder no longer gets stuck with an incomplete result. Resolving a builder for a specific model can re-enter itself, and the partial result returned to break that cycle was stored in the cache as though it were finished. Every later hover, completion, and diagnostic on that builder was then served a class missing its virtual members, @mixin methods, and framework patches, until an edit to the file evicted it. The partial result is now returned to the caller that needs it without being cached.
  • Closing a file while its own native diagnostics are still computing no longer resurrects them. The background worker that runs the native diagnostic passes checked whether a file was still open only once, before starting a computation that can take seconds on a large file, and never again before either of its two write-backs. Closing the file mid-compute left its problems reappearing in the editor moments after the close had cleared them. The open check now happens right before each write, matching how the external-tool scans already behave. Separately, the step that merges a file's fast, slow, and external-tool results into one set read and wrote its six source caches without any coordination, so two of these merges finishing around the same time could interleave and let the one based on staler data land last, undoing a fresher result. That merge is now serialized per file, so the last one to finish is always the one whose sources it actually read.
  • A Laravel Folio page's route name is recognised. Folio derives routes from the filesystem instead of a Route:: call, so a page's own Laravel\Folio\name() declaration was invisible to route(): a route it named was reported as unknown, and completion, hover, and go-to-definition inside route() never found it. The mounted page directory is now discovered from the framework's own withRouting(pages: ...) default and from explicit Folio::route(...) / Folio::path(...)->uri(...)->name(...) registrations, and every named page beneath it is indexed alongside conventional routes, so it resolves, completes, hovers with the page it comes from, and is reached by go-to-definition like any other route.

0.9.0 - 2026-07-20

Added

  • Macro hover shows origin and inferred return types. Hovering on a macro method call now displays a "macro" indicator instead of the generic "virtual" label, distinguishing ::macro() registrations from @method/@mixin synthesized members. When the closure has no explicit return type hint, the return type is inferred from the closure body and shown with an "(inferred)" annotation. Bare $this / self / static returns preserve their keyword form, and method chains like $this->transform(...) use the last method's declared return type directly, preserving $this, static, and generic parameters that the general resolver would flatten to a bare class name. Regular (non-macro) methods with inferred return types also show the "(inferred)" annotation on hover. Contributed by @calebdw.
  • Return type mismatch diagnostics (type_mismatch_return). Functions and methods with a declared return type are now checked against their return statements. Incompatible return values are flagged as errors. Void functions returning a value and bare return; in non-void functions are also flagged. Generators (functions using yield) are skipped. Uses the same conservative is_type_compatible policy as argument type checking to avoid false positives. Contributed by @calebdw.
  • Property type assignment diagnostics (type_mismatch_property). Assignments to typed properties ($this->prop = expr and self::$prop = expr) are checked against the declared property type. Incompatible values are flagged as errors. Only plain = assignments are checked; compound operators (+=, .=, etc.) are skipped. Untyped and mixed properties are not flagged. Contributed by @calebdw.
  • Conditional return types keep an intersection with the matched class. A @return ($x is class-string<T> ? T&SomeInterface : SomeInterface) annotation now resolves the matched branch to the concrete class intersected with the interface, instead of collapsing it to the bare class. Mock factories such as Mockery's mock(Foo::class) and Laravel's $this->mock(Foo::class) therefore resolve to Foo&MockInterface, so their members complete and assigning the result to a Foo-typed property or returning it from a Foo&MockInterface method no longer reports a spurious type mismatch.
  • PSR-4 mismatch diagnostics and rename-based moves. Files now warn when the declared namespace or primary class name does not match the PSR-4 path or filename, with quick fixes to correct them. Renaming a class from its declaration now opens the full FQCN so you can move it between namespaces in one step, and renaming a namespace can rewrite multiple segments at once while moving PSR-4 directories and updating references across the project. Contributed by @calebdw.
  • Case-sensitive autoloading diagnostic. A class reference whose casing differs from the class's actual declaration is now flagged, with a quick fix to correct it. This catches the bug where code loads on a case-insensitive filesystem (macOS, Windows) but fails with a class-not-found error on Linux, because PSR-4 maps the name to a file path and path lookups are case-sensitive there. It covers use imports and inline references to autoloaded classes; built-in classes and same-file references, which never reach the autoloader, are left alone.
  • Completion candidates ranked by dependency provenance. Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (require / require-dev), then transitive vendor dependencies last. The provenance is inferred from composer.json and installed.json during indexing. Contributed by @calebdw.
  • analyze and fix work without composer.json. Both commands now treat a directory that has no composer.json (a WordPress site, a legacy codebase) as a plain PHP project: classes are indexed by scanning the tree and files are discovered by walking the root, so projects that never adopted Composer can be analysed directly. A note on stderr flags the fallback so a mistyped --project-root is not silently analysed as a bare tree.
  • update command. A new phpantom_lsp update subcommand downloads the latest release from GitHub and replaces the current binary. Supports --check (dry run, exit code 1 if update available) and --no-confirm (for CI). Handles .tar.gz (Unix) and .zip (Windows) archives across all 6 supported platforms. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/194.
  • array_map infers the output element type from its callback. The result of array_map now reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars like string or int, so array_map(fn(Item $item): string => $item->id, $items) produces list<string> rather than list<Item>. When the callback has no return type hint, the type is inferred from its body expression, so array_map(fn($item) => $item->id, $items) over a list<Item> also produces list<string>. Fixes #147. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195)
  • Static methods complete on instance access. Member completion after -> now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance ($obj->make()). Static properties remain excluded, as they are only reachable via ::. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/174.
  • Array-callable navigation. Method-name strings in array callables ([Controller::class, 'method'] and [$object, 'method']) now resolve like a real member reference. This makes go-to-definition, find-references, and rename work on Laravel controller actions such as Route::get('/', [IndexPageController::class, 'indexPage']).
  • Array-callable method completion. Typing inside the method-name string of an array callable ([Controller::class, '|']) now offers method name completions from the resolved class, including inherited and trait methods. Works with Class::class constants, $this, and typed variables. (thanks @calebdw)
  • Convert arrow function to closure. A new refactor.rewrite code action converts arrow functions to anonymous closures (fn($x) => $x * 2 to function($x) { return $x * 2; }). Variables from the outer scope are automatically captured via a use() clause. Preserves static and return type hints. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191.
  • @phpstan-sealed tag support. The @phpstan-sealed FooClass|BarClass PHPDoc tag is now recognized. Class names in the tag are treated as type references, preventing false "unused import" diagnostics. Docblock completion also offers the tag. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/190)
  • Magic methods complete when implemented. Magic methods declared on a class (__invoke, __toString, __call, and the rest) are now offered in member completion, so explicit calls like $x->__invoke() autocomplete and support go-to-definition. They are sorted below the regular methods so they never appear at the top of the list.
  • Staleness detection and auto-refresh. The class index, function index, and constant index now stay fresh automatically. When PHP files are created or deleted outside the editor (e.g. git checkout, code generation), the indices update without a restart, and edits made outside the editor are reflected the next time the file is used. When composer.json or composer.lock changes (e.g. after composer install), vendor packages are rescanned automatically.
  • #[ArrayShape] attribute support. Functions and methods annotated with #[ArrayShape(["key" => "type", ...])] (used by ~84 phpstorm-stubs entries) now produce array shape key completions, hover type info, and correct type resolution. Affects commonly used functions like parse_url, stat, pathinfo, gc_status, getimagesize, and session_get_cookie_params.
  • Convert to arrow function. A new refactor.rewrite code action converts single-expression closures to arrow functions (function($x) { return $x * 2; } to fn($x) => $x * 2). The action is only offered when the conversion is safe: single return statement, no by-reference use captures, no void/never return type, and PHP >= 7.4.
  • Convert switch to match. A new refactor.rewrite code action converts switch statements to match expressions when all arms are single-expression returns or assignments to the same variable. Handles fall-through cases (merged with commas), trailing break removal, and throw arms. Requires PHP >= 8.0.
  • Extract interface. A new refactor.extract code action generates an interface from a concrete class. All public method signatures (excluding the constructor) are extracted into a new {ClassName}Interface.php file in the same directory, and the class is updated with implements {ClassName}Interface. Class-level and method-level @template tags are preserved when referenced by extracted methods.
  • @template on @method tags. Virtual methods declared via @method PHPDoc tags can now define their own template parameters using the <T of Bound> syntax (e.g. @method TVal get<TVal of mixed>(TVal $default)). Template inference at call sites works the same as for real methods.
  • Laravel custom Eloquent builder support. Models using the #[UseEloquentBuilder] attribute now have their custom builder's methods forwarded as static methods on the model. query(), newQuery(), and newModelQuery() return the custom builder type with correct generic model substitution. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/118.
  • Eloquent relation and column string completion. Typing inside string arguments to with(), load(), whereHas(), and other Eloquent methods that accept relation names now offers relationship method names as completions, with dot-notation traversal for nested relations. Similarly, where(), orderBy(), select(), pluck(), and other column-accepting methods offer model column names (from $casts, $fillable, @property tags, timestamps, etc.).
  • Authenticated user resolves to the configured model. $request->user(), auth()->user(), and Auth::user() now resolve to the Eloquent model declared in config/auth.php instead of only the bare Authenticatable contract, so completion, hover, and member access work on the concrete model ($request->user()->email). Naming a guard selects that guard's model, so auth('admin')->user(), Auth::guard('admin')->user(), and $request->user('admin') resolve to the model configured for the admin guard rather than the default one. The config is read statically: only the literal default of env('AUTH_MODEL', User::class) is used, never the runtime environment. When a guard, provider, or model could vary at runtime, the result widens to a union of every candidate, and the floor is raised from the abstract contract to the project's own classes that implement it, so a single-model app resolves to just that model while a multi-model app offers each. Members that exist on some candidate resolve; genuinely unknown members still report.
  • Laravel macros are recognized as real methods. A method registered with SomeClass::macro('name', fn (...) => ...), whether in your own service providers or in an installed package's, now appears in completion on that class, shows the closure's parameters and return type on hover and in signature help, and resolves for member access and chaining. Go-to-definition on a macro call jumps to its ::macro(...) registration site, landing on the first character of the macro name string. Both instance ($collection->name()) and static (SomeClass::name()) calls work. A macro registered through a facade (View::macro('extends', ...)) also attaches to the concrete class the facade resolves to, so an instance call on that class ($factory->extends()) resolves as well as the static facade call. Discovery now follows provider-rooted helper classes in both app code and installed packages, whether the provider references the helper through a static call, Foo::class, or new Foo(), and also recognizes typed variable registrations like Builder $query followed by $query->macro(...), including inside callbacks such as function (Builder $builder) { $builder->macro(...); }. Find-references and rename now link the registration string with macro call sites in both directions, including chained collection-style calls such as ->pluck(...)->macroName(), and workspace symbol maps are warmed in the background so repeated workspace-wide rename/reference requests avoid reparsing unopened files.
  • Container string aliases and global facades resolve. resolve('blade.compiler') and app('cache') resolve to the concrete class Laravel binds the string to, so member access on the result completes, navigates, and type-checks, whether the call is chained directly or its result is first assigned to a variable. Bare global facade aliases such as \App and \DB resolve to their facade class without an explicit import. Both alias tables are read by parsing the framework the project actually has installed (never a version-specific list baked into PHPantom), so a name that only a service provider registers stays unresolved rather than being guessed. A project class whose short name collides with a facade alias (e.g. an app's own Request in the current namespace) still wins, since the alias table is only consulted after namespace-aware resolution misses.
  • model-property<T> pseudo-type recognition. The Larastan model-property<Model> type no longer triggers "unknown class" diagnostics. It is treated as a string subtype.
  • compact() strings are linked to local variables. A string argument to compact('user') is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/159.
  • Imported and same-namespace symbols rank first in completion. Classes, functions, and constants that are already imported via a use statement or live in the same namespace now always appear above non-imported symbols in the completion list, regardless of dependency provenance. Previously a non-imported project class could outrank an already-imported vendor class, forcing users to scroll past irrelevant results. Contributed by @calebdw.
  • Laravel route controller method navigation and completion. Method-name strings inside Route::controller(X::class)->group(fn(){…}) closures now resolve as references to the controller's methods. Go-to-definition, find-references, rename, hover, and diagnostics all work on the action string (e.g. Route::patch('cancel', 'cancel') resolves 'cancel' to WorkItemController::cancel()). Autocompletion inside the action string offers the controller's methods. Handles ->controller() anywhere in the fluent chain, chained route calls (->name(), etc.), and nested groups where an inner ->controller() shadows the outer one. Contributed by @calebdw.
  • Package provenance displayed in hover. Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: 🟢 for direct Composer dependencies (e.g. laravel/framework), 🟠 for transitive dependencies with an italic (transitive) marker, and 🟣 for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from vendor/composer/installed.json. Closes #228. Contributed by @calebdw.
  • Diagnostic ignore rules in .phpantom.toml. A new [[diagnostics.ignore]] config section suppresses matching diagnostics project-wide, similar to PHPStan's ignoreErrors. Each rule can constrain by file path (glob), message (regex), and/or diagnostic code, so a project can silence known-noisy paths (test fixtures, vendored code with unavailable stubs) without editor-only @phpantom-ignore comments scattered through the codebase.
  • Built-in formatter respects mago.toml. When formatting falls back to the embedded formatter, a mago.toml at the workspace root is now honoured, applying its [formatter] preset and settings instead of the PER-CS 2.0 defaults. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/233.
  • Rename updates $param in conditional return types. Renaming a function parameter now also renames references to that parameter inside PHPDoc conditional return type annotations (@return ($param is true ? T : U)), including nested conditionals. Previously the @param tag and function body were updated but the @return conditional was left stale. Contributed by @calebdw.
  • @param-closure-this support in hover, go-to-definition, and go-to-type-definition. Hovering on $this inside a closure whose enclosing call site declares @param-closure-this now shows the overridden type instead of the lexically enclosing class. Go-to-definition and go-to-type-definition on $this likewise jump to the overridden class declaration. Previously only completion resolved the override. Contributed by @calebdw.
  • Path-repository packages included in PSR-4 mappings. Local Composer packages installed via path repositories (e.g. internachi/modular modules) are now discovered from vendor/composer/installed.json and their PSR-4 autoload entries are included in the project's namespace mappings. This fixes macro scanning, class resolution, and future namespace validation for modular Laravel projects where application code lives outside the root composer.json's own PSR-4 directories. Only packages whose files live outside vendor/ (symlinked in from a module directory such as app-modules/) count as project source; a path repository that resolves back inside vendor/ is treated as an ordinary dependency, so it is indexed for type resolution but not analyzed as your own code. Contributed by @calebdw.
  • Provenance badges for external path-repository packages. Symlinked path-repository packages whose resolved path is outside the workspace root now correctly show the package name badge in hover instead of being silently treated as project code. Path-repo packages inside the workspace (e.g. modular app modules) continue to show no badge. Contributed by @calebdw.

Changed

  • Laravel analysis runs only in Laravel projects. Eloquent model member synthesis, query-builder forwarding, the contract-to-concrete bindings, and the framework class patches are now gated on the project depending on Laravel or a standalone Illuminate component. Projects that use neither skip that work entirely during indexing and type resolution, so they index faster and never pay for Laravel-specific scanning.
  • More responsive editing. File parsing and diagnostics run in the background, so completion and hover no longer stall behind a full-file parse while you type. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/118.
  • Faster repeat completions. Member completion results are reused between keystrokes, so refining a completion by typing more characters returns instantly. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/118.
  • No first-access delay on Eloquent completions. Common Laravel builder types are prepared at startup, eliminating the pause the first time you complete on a query builder. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/118.
  • Faster code actions. The lightbulb menu now appears more quickly, since all refactorings share a single parse of the file instead of re-parsing it for each one.
  • Lower memory use while indexing. Scanning a workspace for classes, functions, and constants now reads files through the operating system's page cache instead of copying each one into memory, reducing peak memory when indexing large projects and their vendor trees.

Fixed

  • External diagnostics are retained alongside native diagnostics on the same line. PHPStan, PHPCS, and Mago diagnostics that report only a line number are no longer discarded when PHPantom has a more precise diagnostic on that line, so an independent (and possibly more severe) external finding is never hidden behind a minor precise one. When several diagnostics share a line they are now ordered most-severe first, then precise before full-line, so the critical or pinpointed marker leads instead of being buried under a whole-line underline.
  • Deeply nested code no longer crashes the analyzer or editor. Files with very deeply nested expressions, such as the codec tables in WordPress' bundled getID3 library, could abort the whole analyze run (or the language server) with a stack overflow while parsing. Parsing and analysis threads now run with enough stack headroom to handle them.
  • Large procedural files no longer stall analysis. Analyzing a large legacy class that builds up array state across hundreds of conditional branches took minutes per file, long enough to look like a hang, and the same blowup could stall hover and completion in the editor. Methods without a declared return type now have their return type inferred from the body once per request instead of once per call site. The worst observed file went from over two minutes to under a second.
  • Array keys written in only one branch survive the merge. When an if/else writes different keys into the same array variable, the branches now merge into a single shape that keeps every key, marking keys set in only one branch as optional (array{a: int, b?: string}). Previously later writes continued from just one branch's shape, silently dropping the keys tracked in the other, and each branch carried its own shape variant, which made merges increasingly expensive in branch-heavy methods.
  • Laravel date helpers respect Date::use() / Date::useClass(). The configured date class is discovered from project service providers through the Date facade or DateFactory, so now(), today(), Date facade calls, and DateFactory calls resolve to the actual generated type (for example Carbon\CarbonImmutable) rather than the framework's broad CarbonInterface declaration or default Illuminate\Support\Carbon. Variable inference, return diagnostics, and inferred hover returns preserve nullable results such as CarbonImmutable|null; the native Laravel declaration remains visible in hover. Early requests wait for date discovery rather than inferring a stale default class. Adding, changing, or removing the Date::use() call in a provider updates the resolution during the same editing session, and a Date::use() call in a file that is not a registered provider never overrides the project's real configuration.
  • A conditional @return type is evaluated at the call site even when it is declared on an interface. A method whose PHPStan conditional return type narrows a literal-array argument (as in Spatie LaravelData's Data::collect([...]), which yields array<static> for an array) now resolves to that narrowed branch instead of the method's broad declared union. This holds when the conditional is declared on an interface while the concrete method comes from a trait, and the narrowed branch now supersedes the union rather than being discarded (which previously also dropped the union's array member). Member access, hover, and the return- and property-type mismatch diagnostics all see the precise type, eliminating false "incompatible with declared type array<Foo>" reports.
  • new ReflectionClass($class) resolves instances to the reflected type. When the argument is a class-string<T>, newInstance() and newInstanceArgs() now resolve to the object type T (nullable for newInstanceArgs) instead of the class-string, so returning $reflection->newInstanceArgs(...) from a method declared to return that object type no longer reports a false return-type mismatch. More generally, a docblock type now refines a native union that mixes object with a scalar (such as the object|string hint many reflection stubs carry), where it was previously discarded.
  • A property assigned from a function of itself no longer crashes analysis. A self-referencing assignment such as $this->items = array_unique(array_merge($this->items, $more)), where the property is read on its own right-hand side, previously sent type resolution into unbounded recursion that overflowed the stack and aborted the whole analyze run. The property now resolves to its declared type instead.
  • Method-call chains no longer report a spurious unresolved type on one branch but not an adjacent identical one. A variable assigned from a call whose return type is inferred from the callee's body (for example an Eloquent query builder returned by an un-annotated query() method) kept its type across the whole method, so every $query->whereBetween(...)->pluck(...) chain resolves consistently. Previously the receiver's type could be dropped for one branch while an identically shaped adjacent branch resolved cleanly, producing an intermittent "type could not be resolved" warning.
  • A variable destructured from an untyped array can be narrowed by a later assertion. When list-destructuring pulls variables out of a value whose type is unknown ([$type, $variable] = $declarations[0] where $declarations is a bare array), a following assertInstanceOf(Wanted::class, $type) now narrows $type to the asserted class, so member access on it resolves instead of reporting the type as unresolvable. A plain assignment from the same untyped value already worked; only the destructuring form left the variables unnarrowable.
  • assertInstanceOf narrows when the expected class is held in a variable. Passing a variable that holds a ::class value as the first argument ($cls = Wanted::class; assertInstanceOf($cls, $subject)) now narrows the subject the same way the inlined Wanted::class literal does, including when the variable is assigned inside a loop or other braced block or list-destructured out of the array a foreach iterates ([$a, $b, $cls] = $expected;). Previously only the inlined literal narrowed, so the loop-based PHPUnit data-provider pattern left the subject's type unresolved.
  • Narrowing guards apply to array-index subjects. An assertInstanceOf(Wanted::class, $arr['key']) assertion and an is_a($arr['key'], Foo::class, true) class-string guard now narrow the indexed element ($arr['key'], $arr[0]) just like they do for a plain variable, so a following member access resolves and the narrowed class-string satisfies a class-string<Foo> parameter. Previously the narrowing was silently dropped when the subject was an array-index expression, leaving the element's type unresolved.
  • A for loop's init-clause variable resolves in the condition and update clauses. A variable assigned in the init clause of a for loop (for ($p = $e->getPrevious(); $p; $p = $p->getPrevious())) now has its type available in the condition and update expressions on the same for line, so member access there resolves instead of reporting the type as unresolvable. Previously only the loop body saw the variable.
  • A closure parameter's declared type is kept when the collection's element type is a partial union. Passing a closure to a method like filter() on a subject that is a union of differently parameterized collections (Collection<CanApply>|Collection<ViewModel>|Collection<stdClass>) no longer collapses the closure parameter to the first collection's element type. When the parameter declares its own union (function (CanApply|ViewModel|stdClass $item)), that declared type is preserved, so member access inside the closure body resolves against every arm instead of falsely reporting a property missing on the first one.
  • Callable return templates bind from an unannotated closure's typed parameters. A template parameter that appears only in a callable argument's return position (as in a collection's reduce(), declared @param callable(TCarry, TValue): TReturn with @return TReturn) now resolves when the closure has no explicit return type but its body's type follows from its own parameter hints. $items->reduce(fn(Decimal $carry, $op) => $carry->add($op->getPrice()), new Decimal('0')) resolves to Decimal, so member access on the result completes, navigates, and type-checks.
  • Indexing an array with a dynamic key resolves the element type. Accessing an array shape with a variable key ($prices[$priceToUse]) now resolves to the union of the shape's value types, so member access on the result completes, navigates, and type-checks. Writes through a dynamic key are tracked too: a map built in a loop ($sums[$id] = $this->getStructure()) reads back element-by-element, and a nested write mixing literal and dynamic keys ($return['data'][$count]['earnings'] = $price) can be read back through the same key path.
  • Analysis results no longer vary between runs of the same project. Two timing-dependent flaws could make type resolution silently fail depending on which files were analyzed together: a thread waiting for another thread's in-progress parse of the same file gave up after a fixed timeout and cached the class as nonexistent for the rest of the session, and looking up a built-in constant re-registered the built-in functions defined alongside it without their signature corrections, losing array_map's template parameters. Both mainly surfaced as closure parameters that wouldn't resolve (array_map(fn($e) => $e->value, ...) reporting the type of $e as unresolvable) in full-project analyze runs and in long editor sessions, while analyzing the same file alone worked. Full-project diagnostics are now identical across repeated runs.
  • An array<T>|false return keeps its element type after a false check. A function typed array<int, User>|false (native array|false refined by a docblock) now retains the array's element type, so after if (!is_array($result)) return; or if ($result === false) return; the surviving array iterates to the declared element instead of losing it. Previously only the |null variant worked; the |false union dropped the docblock entirely, leaving the foreach value unresolved.
  • Leading-backslash global function calls resolve in member chains. A helper call written with an explicit global-namespace prefix, such as \response()->json(...), now resolves its return type the same way the unqualified response()->json(...) does, so member access, completion, and navigation on the result work.
  • A guard that reassigns one path keeps the narrowed type on the other. When a variable holds a partially-known type (a class or enum combined with an unresolved value) and a guard like if (!$type instanceof Country) { $type = Country::ADMIN; } reassigns only the failing path, the variable now correctly resolves to the narrowed type after the guard. Previously the unresolved component caused the narrowed fall-through type to be dropped, leaving the variable with no type, so member access on it reported the type as unresolvable.
  • Conditional return types with generic static<...> branches keep their type arguments. A method whose @return is a PHPStan conditional (($flag is true ? static<int, static> : static<int, static<int, TValue>>)) now resolves through to the fully substituted type instead of collapsing to a bare class name. This most visibly affected Laravel's Collection::chunk(), where iterating the result (foreach ($items->chunk(500) as $batch)) gave $batch no resolvable type; the nested collection element now resolves so member access on it completes, navigates, and type-checks.
  • Conditional return types whose selected branch is mixed stay usable. A call whose conditional @return resolves to mixed (such as Laravel's session($key) with ($key is string ? mixed : null)) now gives the value the type mixed instead of leaving it untyped. The value can then be narrowed as usual, so a later is_string()/instanceof guard refines it rather than being ignored, which removes a false-positive "expects string, got null" argument error after such a guard. Out-of-order named arguments in a conditional call (map(source: $x, signature: Foo::class)) also now bind to the parameter they name, so the branch keyed on that parameter resolves correctly regardless of argument order.
  • Fluent chains through a trait's return $this keep the using class. A trait method that returns $this without a declared return type now resolves to the class that uses the trait, so a chained call continues with that class's own members (its other traits, properties, and methods) instead of narrowing to the trait after the first call. This most visibly affected fluent test-assertion helpers, where every step past the first ->assert...() call reported unknown members.
  • Inline test fixtures no longer trigger PSR-4 mismatch warnings. Files that mix a top-level test call (e.g. Pest's it(...)/describe(...)) with an inline enum, trait, class, or interface now skip the namespace and filename mismatch diagnostics, so helper fixtures embedded in test files no longer produce noisy PSR-4 warnings. Regular single-class PSR-4 files, including ones with ordinary top-level statements like if guards, still report normally. Contributed by @calebdw.
  • Pull-diagnostic editors no longer show native diagnostics twice. When an editor supports pull diagnostics, PHPantom now delivers native diagnostics through the pull path only, while still refreshing quickly after the fast pass. This keeps namespace, class-name, and other native diagnostics responsive without duplicating them in clients that keep pushed and pulled diagnostics separate. Contributed by @calebdw.
  • Extract Variable no longer appears on declarations. The Extract variable refactor is now only offered inside executable function and method bodies, so selecting a class or trait name no longer suggests introducing a meaningless local variable. Contributed by @calebdw.
  • @phpstan-require-extends gives $this the base class's members inside a trait. A trait annotated @phpstan-require-extends Base can now use Base's methods, properties, and constants on $this when the trait is analyzed on its own, so completion, hover, member access, and go-to-definition work on those members instead of reporting the type as unresolvable. Previously this only worked when viewing the code through a concrete class that used the trait.
  • Parenthesized return types resolve instead of being dropped. A method annotated with a grouped @return type such as (Foo&object{pivot: Bar})|null now resolves correctly. Previously any @return starting with ( was mistaken for a conditional return type and discarded, so the method silently inherited an ancestor's raw template parameter. This most visibly affected Eloquent relations: chaining off $model->relation()->first() now resolves to the related model rather than an unresolvable type.
  • Indexing a call result inline resolves the element type. Chaining off an indexed method or function call ($node->findChildrenOfType(Attr::class)[0]->getParent()) now resolves the element type instead of breaking the chain, so member access on it completes, navigates, and type-checks. When the call is declared @return T[] with a class-string<T> argument, the element is inferred from the argument at the call site. Enum cases() results resolve too: Status::cases()[0]->value knows the element is the enum, since cases() returns a list of the enum's own instances.
  • Member-existence guards prove the member exists. Accessing a member inside a branch guarded by property_exists($obj, 'name'), method_exists($obj, 'name'), or isset($obj->name) is no longer reported as an unknown member, matching how PHPStan treats the guard as proof for the rest of that branch. The proof holds in if statements and ternary conditions alike, through && chains and negated guard clauses that return early, and is confined to the guarded branch, so the same access elsewhere still reports.
  • assertTrue/assertFalse prove their wrapped condition. A check wrapped in assertTrue(...) or assertFalse(...) (any method carrying @phpstan-assert true/false $condition, as PHPUnit's do) now narrows exactly like the equivalent if guard, because the assertion re-exports its inner condition. assertTrue(property_exists($model, 'value')) proves the property for the rest of the scope, and assertFalse($x instanceof Foo) excludes Foo from $x's type.
  • PHPUnit's assertIs* / assertIsNot* narrow to the asserted type. An assertion of a scalar or pseudo-type (@phpstan-assert string/int/float/bool/object/array/callable/numeric/scalar $x, as assertIsString, assertIsObject, assertIsArray, and the rest carry) now narrows the value like the matching is_*() guard, and the assertIsNot* negations exclude that type from a union. In particular, asserting a value is an object lets subsequent member access resolve instead of being flagged as unresolved.
  • A class_exists() guard keeps a variable's concrete class-string type. A variable typed class-string<Foo> (via @var or @param) that then passes through a class_exists($var) guard clause (if (!class_exists($var)) { throw; }) now keeps its <Foo> type argument instead of being widened to a bare class-string. As a result new $var() still resolves to Foo, so member access on the resulting object continues to work.
  • Each if/elseif branch narrows a property path to its own type. A property or array-element path ($args[0]->value) narrowed by instanceof in one branch no longer leaks that type into a later elseif branch that narrows the same path to a different type. Member access in the second branch now resolves against the second branch's type instead of falsely reporting a missing member from the first branch's type.
  • instanceof narrows a parameter inside an arrow-function body. In fn($x) => $x instanceof Foo && $x->method(), the parameter $x narrowed by the first && conjunct is now visible to the member access in a later conjunct, so completion, hover, and member access resolve against Foo instead of reporting the type as unresolvable. This matches how the same && narrowing already worked outside arrow functions.
  • Generic inference binds through call-expression arguments. A @template parameter constrained by array<T> now binds when the argument is a method or function call whose return type is an array (first(self::getEmailConfigs())), not only when it is a variable or an array literal. Closure parameters that share a name with an outer variable now shadow it unconditionally, so they no longer silently borrow the outer variable's type.
  • array_map and array_filter type their callback parameter. A closure passed to array_map or array_filter now has its parameter inferred from the array's element type, including when the array is itself a method or function call (array_map(fn($node) => $node->getImage(), $obj->getChildren())), so member access inside the callback resolves.
  • A templated helper with a class-string default resolves when called with no arguments. A container-style accessor declared @template T of object, @param class-string<T> $name, @return T with a Foo::class parameter default now binds T from that default when called with no arguments, so $app = app() resolves to the default class exactly as app(Foo::class) binds to Foo. Member access on the result completes, navigates, and type-checks instead of reporting the type as unresolvable.
  • Class-string unions carry through a foreach over an array-literal variable. Iterating a variable assigned a list of ::class constants ($repos = [A::class, B::class]; foreach ($repos as $r)) now resolves each element to its class, so a call like app()->make($r) binds its class-string<T> template to the union and the chained call resolves.
  • foreach over SPL iterators resolves the element type. Iterating an SPL iterator whose generics carry a third inner-iterator argument (@extends FilterIterator<int, SplFileInfo, ...> or @var AppendIterator<int, SplFileInfo, ...>) now types the value variable as the middle value type (SplFileInfo) instead of the inner iterator. Iterating a directly-constructed SPL iterator (foreach (new DirectoryIterator($dir) as $file)) also resolves the value type through the class's current() method, so members like $file->isFile() and $file->getRealPath() complete, navigate, and type-check.
  • An inline @var before a foreach refines a broad iterable variable. A /** @var iterable<Foo> $items */ placed just before foreach ($items as $item) now types the loop variable even when $items already carried a broad type such as mixed (common for mixed closure or function parameters) or a bare array. Previously the broad type occupied the variable and the annotation was ignored, so member access on the loop variable reported the type as unresolvable. The same works when the iterable is a method chain (foreach ($users->active() as $u)): a @var naming the base variable types it for the loop, whether the variable was previously untyped, broadly typed, or deliberately overridden.
  • compact() with an array argument counts its variables as used. Variables named inside an array passed to compact() (compact(['a', 'b']), including nested arrays) are no longer falsely reported as unused or undefined. Rename, find-references, and go-to-definition also work on the names inside the array, matching the existing behaviour for direct string arguments.
  • A variable used only as a dynamic member name is no longer reported unused. A variable read solely as the method or property selector in a dynamic access ($obj->{$name}(), $obj?->{$name}, Cls::{$name}()) now counts as used, so it is no longer flagged by the unused-variable diagnostic.
  • Values typed as a Laravel contract resolve through their concrete class. Calling a method on a value type-hinted as a core Illuminate contract (such as the view contract) no longer reports a false "method not found" for methods the framework's default concrete handles dynamically (macros and other __call-dispatched calls). The concrete is bound to the contract, so its members are visible for completion and hover and its magic-method dispatch suppresses the spurious diagnostic.
  • Eloquent relations resolve regardless of the case used to access them. Accessing a relationship as a property with different casing than the method declaration ($order->orderproducts for an orderProducts() relation) now resolves the same relationship for hover, completion, chaining, and diagnostics. This matches Laravel's runtime behaviour, where the magic accessor resolves relations through a case-insensitive method lookup, so a differently-cased access is no longer reported as an unknown member.
  • $this inside an anonymous class resolves to that class. Members accessed on $this within an anonymous class's own methods (return new class { function get() { return $this->value; } }) now resolve against the anonymous class instead of the class whose method contains the new class { ... } expression, so its properties and methods no longer report as unknown.
  • Array literals that look like callables are no longer flagged as bad method calls. A two-element array such as [Foo::class, 'name'] or [$object, 'name'] is only a callable when it is actually used as one, but plain data often takes the same shape (a list of [class, label] pairs, or an array passed as data to array_filter). Diagnostics no longer report the second element as a missing method in these cases, eliminating false "method not found" errors on ordinary data. Go-to-definition, find-references, and rename on genuine array callables still work.
  • A class named after a built-in resolves to the project's version. Inside a namespace, new Iterator() (and other unqualified class references) now resolves to a same-namespace class of that name before falling back to the global PHP class of the same short name, matching how PHP itself resolves names. Previously a project class such as App\Input\Iterator lost to the global SPL \Iterator, so every member on the instance was reported as unknown. An explicit use import still takes precedence.
  • Conditional return types are evaluated at call sites, even nested in a generic. A PHPStan conditional type embedded in a method's generic return (as on Laravel's Collection::groupBy/keyBy) is now collapsed against the call arguments, so the resulting collection carries a concrete key type. Calling a method on that result ($grouped->get('id')) no longer reports a spurious argument-type error printing the raw conditional. When a conditional's subject is an expression rather than a literal (such as $subject in Str::replace(..., $obj->toHtml())), the argument's resolved type selects the branch; when the type genuinely cannot be determined, both branches are kept as a union instead of committing to the wrong one.
  • isset() and empty() guard their own access. Checking isset($obj->prop) or empty($obj->prop) no longer reports the property as unknown or unresolved, even when the subject's type is a union that includes stdClass. Neither construct ever errors at runtime when the member doesn't exist, so flagging them was always a false positive.
  • A method returning object or ?object allows member access on its result. Accessing a property or method on the result of a call whose return type is object (or the nullable ?object) is now treated as the "any object" escape hatch it is, so $repo->all()->projects no longer reports the subject type as unresolvable. Nullability no longer discards the object type.
  • Assigning an object to a property tracks that property's type. After $settings->cache = new stdClass(), reading $settings->cache resolves to stdClass, so a further access like $settings->cache->ttl no longer reports the subject as unresolvable. This makes nested object graphs built up field by field (a common stdClass configuration pattern) resolve for hover, completion, and diagnostics. Assigning null to a property tracks it as null too, but a later not-null assertion (assertNotNull($obj->prop), @phpstan-assert !null) now clears that tracked null, so member access after the assertion is not falsely flagged as access on null.
  • A type guard trusts the runtime check over an incomplete static type. When is_object($x) (or is_string(), is_array(), and similar checks) succeeds but $x's inferred type didn't account for that possibility (for example a foreach element under-inferred from a custom iterator), the guarded branch now takes the guard's asserted type instead of keeping the stale one. This clears spurious "cannot access property/method on scalar" warnings inside these guards.
  • is_a($value, Class::class, true) and class_exists($value) narrow a string to class-string. With the allow_string argument, is_a() accepts a class-name string as well as an object, and now narrows accordingly to class-string<Class> rather than an object instance. class_exists(), interface_exists(), enum_exists(), and trait_exists() narrow to the generic class-string. This also narrows through guard clauses (if (!is_a(...)) { throw ...; }).
  • is_numeric() on a string narrows to numeric-string, not a bare number. The narrowed type previously dropped the string possibility entirely, so passing the checked value on to a string parameter reported a spurious mismatch.
  • A bare truthy check strips null from the checked variable. if ($value) { ... } now removes null (and false) from a nullable type inside the branch, matching the existing behavior of isset() and !== null checks.
  • Type-guard narrowing survives compound conditions and non-variable subjects. instanceof and assert narrowing now holds beyond a single negated-variable guard. It carries across && chains (a later conjunct and the body see an earlier conjunct's narrowing), through || guard clauses that narrow several distinct subjects at once, and applies to property paths, array-indexed elements ($stmts[0], $args[0]->value, $config['key']), and inline assignments in the condition (if (($node = expr()) instanceof Foo)). A @phpstan-assert on a property or indexed argument narrows later accesses to the same expression. This clears a large class of false "property/method not found" warnings in code that guards nested expressions, and the same narrowing now feeds completion and hover.
  • An assertInstanceOf with a variable class keeps the subject's type. When the asserted class is a runtime variable that cannot be resolved to a concrete class (static::assertInstanceOf($expectedClass, $node)), the assertion no longer erases the subject's type. It narrows to object intersected with the prior type, dropping null while keeping the class the subject already had, so a following member access such as $node->getImage() resolves instead of reporting a spurious unresolved-type warning.
  • array-key satisfies an int|string parameter. Passing a value typed array-key to a parameter expecting int|string no longer reports a spurious type mismatch. The two are equivalent, and the subtype check now treats them as such in both directions.
  • A class-string<A|B> value satisfies a class-string<T> template parameter. Passing a value typed class-string<A|B> to a generic parameter typed class-string<T> no longer reports a spurious mismatch. The whole union now binds the template rather than collapsing to its first member, and the value keeps its class-string wrapper.
  • A namespaced class name passed as a string literal resolves. A single-quoted class-string argument such as $repo->find('App\\Models\\User') now names the class App\Models\User, with the source backslash escape collapsed before lookup. Previously the doubled backslash was kept verbatim, so the generic result stayed unresolved and member access on it reported spurious unresolved-type warnings. Container lookups by class name (app('App\\Services\\Foo')) resolve the same way.
  • @see Class#method docblock references resolve the class. Legacy phpDocumentor fragment syntax (@see ASTNode#getMetadataSize) previously looked up the whole Class#method string as a single class name and reported it as unknown. The class and member are now split and validated independently, the same as the Class::method form.
  • @mixin of an Eloquent model exposes the model's synthesized members. A plain class annotated @mixin SomeModel now receives the model's virtual members (relationship properties, scope methods, cast-typed attributes, accessors), not just its declared ones. Accessing a relationship such as $cart->linkCampaign or $cart->items through the mixin resolves the same as it does on the model itself, so completion, hover, and member access work and no longer report spurious unresolved-member warnings.
  • @mixin of a template parameter resolves through its bound. A class annotated @mixin T where T is a @template T of SomeType parameter now exposes SomeType's public members, so member access, completion, and hover work on the wrapper class itself even when no concrete type is bound. When the mixin lives on a base class and a subclass tightens the constraint (AbstractNode<T of Node> extended by CallableNode<T of Callable>), members resolve through the most specific bound in the chain.
  • A method-level template bound to an array type resolves inside the method's own body. A @template T of SomeType[] parameter used as a pass-through (@param T $items / @return T) left T unresolved when accessed inside the method itself, so calls like end($items)->method() reported the member as unknown. Member access, hover, and completion on the parameter now resolve through the declared bound.
  • $this narrowed by assert() resolves inside closures with no enclosing class. In a top-level test closure (such as a Pest it(...) body), assert($this instanceof TestCase) now makes $this resolve to that class, so a value assigned from $this->method() carries the method's return type into the rest of the closure. Member access on those variables no longer reports spurious unresolved-type warnings. instanceof narrowing of $this to a subclass inside a regular method now resolves the subclass's members as well.
  • Values returned from a callback passed to a generic helper now resolve. When a method or function binds a template parameter from a closure's return type (@param \Closure(): T $callback, @return T), the result resolves even when the closure is an unannotated arrow function or block closure, inferring the type from the closure body. Laravel's Cache::remember($key, $ttl, fn() => new Order()) resolves to Order (as do rememberForever, sear, flexible, and withoutOverlapping), so property and method access on the cached value no longer reports spurious unresolved-member warnings.
  • Paginated Eloquent results carry their model type. Iterating Model::paginate(), simplePaginate(), or cursorPaginate() now resolves the loop variable to the model, so foreach (User::paginate() as $user) gives $user the concrete model type and member access on it resolves.
  • Storage::fake() resolves to the concrete filesystem adapter. Storage::fake() and Storage::persistentFake() now resolve to the FilesystemAdapter they actually return rather than the bare filesystem contract, so the assertion helpers used in tests (assertExists, assertMissing, and the rest) complete and resolve on the faked disk.
  • Static method calls see inherited and framework-corrected return types. A Class::method() call now resolves its return type through the class's full inheritance and interface chain, matching how instance calls already behaved. This clears cases where a static call to a method whose precise return type comes from a parent, an interface, or a framework type correction previously resolved to an imprecise type.
  • A project class sharing a global interface's short name no longer breaks subtype checks. When a file imports a project class whose short name matches a global interface (e.g. use App\Input\Iterator;), subtype checks against the global \Iterator or \Traversable kept working. Previously the import shadowed the global interface everywhere in the file, so passing an SPL iterator (RecursiveDirectoryIterator, GlobIterator, RecursiveIteratorIterator) to a parameter typed against the global interface reported a spurious argument type mismatch.
  • Indexing a positional array shape resolves the element type. Given /** @var array{Foo, Bar} $pair */, accessing $pair[0] now resolves to Foo and $pair[1] to Bar. Previously only string-keyed shapes (array{name: string}) resolved through bracket access; positional (tuple-style) shapes indexed with an integer literal reported an unresolved type. Shapes written across multiple lines resolve too, so a @var array{...} block whose entries are listed one per line works the same as a single-line one.
  • Class::class resolves to a class-string. The magic ::class constant now resolves to class-string<Class> instead of a plain string, so the class identity survives through assignments, array elements, and class-string<object> parameters.
  • Indexing an inferred tuple with a class-string fallback no longer widens to string. When a nested array literal is used as a fixed tuple (e.g. iterating [['int', $id], ['array', $list, Type::class]]), integer-literal indexing resolves the element at that position, and a $row[2] ?? Fallback::class expression keeps the value a class-string rather than collapsing to string. Passing the result to a class-string<object> parameter no longer reports a spurious type mismatch.
  • Method calls handled by __call / __callStatic are no longer flagged as unknown. When a class (or any branch of a union type) defines a magic call handler, an unrecognized method call is dispatched to it at runtime and is valid PHP, so it no longer produces a warning. This removes false positives on mock and fluent APIs (Mockery higher-order messages), dynamic query builders, and proxy objects. The call's chain type is still recovered from the magic method's return type, so subsequent links keep resolving.
  • A mock built with a test helper keeps the mocked class. $this->mock(Foo::class), partialMock(), and spy() now resolve to the intersection of Foo and the Mockery mock contract, matching Mockery::mock(). The mock therefore satisfies a parameter or array element typed Foo (so new Result([$this->mock(Rule::class)]) against an array<Rule> no longer reports a spurious mismatch), still passes to a method expecting the mocked class, and keeps resolving mock-expectation chains such as shouldReceive()->with().
  • Variables captured by reference in a closure are no longer flagged as unused. A local variable captured with use (&$var) and written inside the closure (e.g. an accumulator passed to array_walk) is now recognized as used, since the write propagates back to the outer scope through the reference.
  • Passing null to an implicitly-nullable parameter is no longer flagged. A parameter keeps its ability to accept null in two cases the type checker previously lost: when a docblock @param narrows a nullable native hint (a @param Foo[] over a native ?array still accepts null), and when the parameter has a literal null default (Type $x = null, the pre-8.4 implicit-nullable form). Calls passing null to such parameters no longer report a spurious "expects ..., got null" mismatch.
  • A string literal naming a class satisfies a class-string<Bound> parameter. Passing a quoted class name, such as $this->expectException('RuntimeException'), no longer reports a type mismatch when the named class satisfies the expected bound. The diagnostic still fires when the literal names a class that is provably unrelated to the bound.
  • Passing a class constant to a generic parameter infers the constant's value type. A call like static::assertSame(Command::INVALID, $exitCode) where INVALID is an untyped int constant now binds the template parameter to int instead of the constant's owning class, so it no longer reports a spurious "expects Command, got int" mismatch on the second argument.
  • Passing a class name to a class-string<T> generic parameter infers the class, not the string type. Calls like $this->assertInstanceOf('Iterator', $value) bind the template to the class the argument names, so the parameter no longer resolves to the nonsensical class-string<string>. A bare class-string value is likewise accepted, resolving the parameter to class-string<object> rather than reporting a spurious mismatch.
  • A union of class names passed to a generic class-string<T> parameter binds each member. Iterating a class-constant array (foreach ([Page::class, CustomPage::class] as $c)) and passing the loop variable to a @template T of Bound parameter typed class-string<T> now binds T to the union of the concrete classes, checking each against the bound through its full inheritance chain. The call no longer reports a spurious mismatch against the declared bound, and a @return T[] resolves to the union of the concrete element types rather than collapsing to the bound.
  • A ::class argument bound to a bare template parameter no longer reports a spurious mismatch. When a template is bound directly from the call-site argument (@param T $x with SomeClass::class), it now infers the argument's actual class-string<SomeClass> type instead of the bare class name, so the parameter is no longer compared as SomeClass against the very class-string<SomeClass> argument that bound it. This clears false positives on the common Mockery::type(SomeClass::class) pattern. Parameters that accept either a class name or an instance via a class-string<T>|T union, including the variadic array shape used by Mockery::mock(SomeClass::class), still bind T to the named class itself, so the returned value satisfies parameters typed with that class.
  • A generic helper call no longer borrows a type from an unrelated call site. When the same call text appears in two places with a differently-typed argument (e.g. $this->parse($stmt) in two methods where $stmt holds a different subtype), each call now resolves independently. Previously the type inferred at the first call site could leak to the second, producing a spurious argument type mismatch such as "expects ForStatement, got WhileStatement".
  • Iterating an object that implements Iterator directly now resolves the loop variable's type. Previously only IteratorAggregate and classes with an explicit generic annotation (@implements Iterator<Key, Value>) resolved a foreach loop variable's type; a class implementing Iterator itself fell through to unresolved. This most commonly affected SimpleXMLElement: foreach ($xml->children() as $child) now resolves $child to SimpleXMLElement, so $child->getName() and friends no longer report an unknown member.
  • The error-suppression operator (@) no longer blocks type resolution. A variable assigned from a suppressed expression, such as $xml = @simplexml_load_string($content);, now resolves to the underlying call's return type instead of being left unresolved. Member access on the variable no longer reports a false unresolved_member_access.
  • Assignments written inside a condition are now tracked. A variable assigned in an if or while condition is recognized as a definition, including the bare negated guard if (!$item = find()) { return; } and the call-wrapped form while (is_object($token = $iter->next())). The variable resolves in the guarded code and loop body instead of being reported as unresolved, clearing a bucket of false positives on $token-style tokenizer loops and early-return guards.
  • Short-circuit conditions narrow their later operands. Within a single || or && condition, an instanceof (or other guard) in one operand now narrows the variable for the operands that follow it. Because the right side of || runs only when the left is false, the common guard idiom if (!$x instanceof Foo || !$x->method()) { continue; } resolves $x->method() against Foo instead of reporting the method as unknown, and the && mirror ($x instanceof Foo && $x->method()) narrows the same way. Nested chains such as ... || ($x instanceof Foo && $x->method()) narrow correctly too. This clears a bucket of false positives on defensive guard code.
  • Assertion methods narrow types through inheritance. @phpstan-assert and @psalm-assert annotations now narrow the asserted variable no matter how the method is reached: through $this->, self::, static::, parent::, or a subclass name, not only when the call names the declaring class directly. This is the PHPUnit shape ($this->assertInstanceOf(Foo::class, $value), static::assertNotNull($value)), so member access and completion after an assertion in a test method now resolve to the asserted type instead of reporting the variable as unresolved. The exact-type prefix these annotations use (@phpstan-assert =Foo) is also parsed correctly, both for narrowing and so it no longer produces a bogus "unknown class" warning on the docblock. This clears a large bucket of false positives across PHPUnit-based test suites.
  • Symfony polyfill packages now classified as PHP core. Packages like symfony/polyfill-php83 that backport PHP core classes and extension functions (e.g. \Override) are now treated as core stubs instead of transitive vendor dependencies. This gives them the correct sort priority in completion and the correct provenance in hover. Contributed by @calebdw.
  • Misspelled members are no longer colored as valid code. Semantic highlighting now verifies that a member exists on the resolved class before coloring it as a method or property. A method-name string in an array callable like [Controller::class, 'sort'] keeps its plain string coloring when the method does not exist, and the same applies to static calls and $this accesses on unknown members. Classes with __call, __callStatic, or __get catch-alls keep their coloring, and members that do exist now also carry deprecated and static styling. Fixes #187.
  • Semantic highlighting no longer goes stale while typing. After an edit finishes parsing in the background, the server asks the editor to re-pull semantic tokens, so coloring reflects the current code instead of the state from before the edit.
  • namespace and use declarations keep the editor's own coloring. PHPantom no longer paints single tokens across the full import paths in regular PHP files, which visibly overrode the per-segment coloring of the editor's syntax grammar. Blade files still receive these tokens since no PHP grammar is active there.
  • Overloaded PHP functions no longer trigger false type_mismatch_argument diagnostics. Functions with multiple signatures like strtr(string, string, string) and strtr(string, array) now store alternate parameter lists. The type checker tries all overloads and only flags a mismatch when the call is incompatible with ALL signatures. Contributed by @calebdw.
  • iterator_to_array() now correctly returns an array type. Previously iterator_to_array($iter) where $iter was Iterator<Foo> resolved to Iterator<Foo> instead of array<Foo>, producing false type_mismatch_argument diagnostics when passed to array-typed parameters. Both key-value (Iterator<int, Foo> to array<int, Foo>) and value-only (Iterator<Foo> to list<Foo>) generic params are preserved. Contributed by @calebdw.
  • Reassigning a variable from its own array offset now updates the type. $value = $value[0] after $value held list<string>|false now correctly narrows $value to string. Previously the scalar element type was skipped during array-access resolution, leaving the variable with its old array type and producing false type_mismatch_argument diagnostics. Contributed by @calebdw.
  • @phpstan-type / @psalm-type aliases no longer trigger false type_mismatch_argument diagnostics. Local type aliases are expanded to their underlying types before argument compatibility checking, and an alias imported from another file is no longer mistaken for an unknown class. Contributed by @calebdw.
  • Reassigning a variable inside an if branch no longer leaks into later elseif conditions or the else branch. A variable changed in one branch is resolved against its pre-branch type in a following elseif condition, elseif body, or else body, so member access and argument checks there no longer report false diagnostics. Contributed by @calebdw.
  • Type narrowing works through the alternate if:/endif; syntax. A failed condition now narrows types in later elseif/else branches and in the scope after the block, matching the brace syntax. For example, after if ($x === null): $x = new Foo(); endif; written with colons, $x is now known to be non-null past endif;.
  • Ternary conditions narrow property and method-call subjects. An instanceof check in a ternary condition now narrows a property or method-call subject inside the branch, so $this->node instanceof Artifact ? $this->node->getCompilationUnit() : null resolves the then-branch instead of reporting the method as unknown. Because the branch resolves, the ternary's type is the union of both branches rather than collapsing to the else-branch, which also clears the cascading false positives that followed (member access reported on null). Previously only plain variable subjects narrowed in ternaries.
  • int<0,max> no longer triggers a false type_mismatch_argument against non-negative-int. Integer range types (int<min,max>) are now checked for subtype compatibility with refined-int pseudo-types (positive-int, negative-int, non-negative-int, non-positive-int) and vice versa. Range-to-range (int<1,50> <: int<0,100>) and cross-refined (positive-int <: non-negative-int) subtyping also work. Contributed by @calebdw.
  • Intersection types with extra members are no longer falsely flagged as type mismatches. A value whose type carries more intersection members than required now satisfies a narrower intersection, so argument type checks accept it. This clears false positives on the common mock pattern where Mockery::mock(Foo::class) (typed Foo&MockInterface&LegacyMockInterface) is passed to a parameter typed Foo&MockInterface.
  • A class named after a pseudo-type is no longer shadowed by it. A class whose name collides with a PHPDoc pseudo-type, most importantly PHP 8.4's BcMath\Number, resolves to the real class instead of the number pseudo-type. Members on such a value resolve, and passing it to a parameter of the same class type no longer raises a false type_mismatch_argument. Fixes #170.
  • Resource-to-object migrated handles no longer trigger false argument type errors. Functions whose handles became objects in PHP 8.1+ (finfo_open, imap_open, ftp_connect, ldap_connect, pg_connect, pg_query, pspell_new, and similar) now return the object type matching your configured PHP version instead of the legacy resource|false. Passing the handle on to finfo_file, imap_close, and the like no longer reports a spurious type_mismatch_argument. Fixes #164.
  • stream_bucket_make_writeable() results resolve on PHP versions before 8.4. The bucket object it returns accepts arbitrary properties at runtime (its class only formally exists from PHP 8.4 onward), so member access like $bucket->data inside a stream filter's filter() method no longer reports an unresolved-member warning on older configured PHP versions.
  • External formatters no longer corrupt the connection. Running php-cs-fixer or PHP_CodeSniffer for document formatting could kill the language server: the tool inherited the editor's input channel and consumed bytes meant for the server, dropping the connection and forcing a restart. Formatters now run with their input isolated. A timeout also names the tool that was too slow (php-cs-fixer timed out after 10000ms) so the culprit is clear, and the timeout can be raised with [formatting] timeout in .phpantom.toml. Fixes #149.
  • "Go to Declaration or Usages" from a declaration now lists usages. Invoking go-to-definition while the cursor is on a class, interface, trait, enum, or member declaration returns the symbol's usages instead of the declaration's own location. Editors that navigate straight to the definition result (such as PHPStorm) previously did nothing but move the cursor onto the name; they now jump to the usage or show the usage list. Fixes #125.
  • Generator closures now propagate template params through make()-style methods. When a closure containing yield expressions is passed to a method with a union param type like iterable<TKey, TValue>|(Closure(): Generator<TKey, TValue>), the yielded value and key types are inferred from the closure body (casts, literals) and used to bind the method's template parameters. This fixes LazyCollection::make(function() { yield (string) $x; }) resolving as LazyCollection<Closure, Closure> instead of LazyCollection<int, string>. Contributed by @calebdw.
  • Foreach key type resolves through a generic IteratorAggregate. When iterating a class that implements IteratorAggregate<non-empty-string, SplFileInfo>, the loop's key variable previously fell back to int|string; it now resolves to the declared key type, matching how the value type already resolved. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/216.
  • Generic<T>[] docblock types now correctly resolve to arrays. The [] array suffix was dropped when it followed a generic type (e.g. ReflectionAttribute<T>[]), a brace-delimited shape (e.g. array{id: int}[]), or a parenthesized group. The type tokenizer now consumes trailing [] suffixes before splitting on union/intersection operators, so these types parse correctly. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/215.
  • Edited functions and constants no longer go stale. Deleting or renaming a standalone function, or changing a define()/const value, is now reflected immediately in completion, hover, and go-to-definition. Previously a removed function kept being offered and jumped to a stale location, and editing a constant's value kept showing the old value, for the rest of the editing session.
  • Reloaded files no longer leave ghost classes. When a file loaded outside the editor (a vendor file, a bundled stub, or a file re-opened after being closed) is parsed again after its contents changed, a class that was renamed or removed no longer keeps resolving from its old definition. Go-to-implementation and type hierarchy stop listing classes that no longer extend a parent, and completion no longer surfaces the deleted class.
  • Integer literals now satisfy integer range parameter types. Argument diagnostics now treat literal integers as valid for int<min, max> and int<min..max> constraints when the value falls within the declared bounds. This fixes false positives like usleep(10_000) against int<0, max> and Laravel-style calls such as repeatEvery(1) against int<1, 59>. Contributed by @calebdw.
  • += on arrays now infers array instead of int|float. The compound assignment operator += is overloaded in PHP: it performs array union when both operands are arrays, but PHPantom was unconditionally treating it as numeric addition. The binary + operator already handled this correctly; the += path now mirrors that logic. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/214.
  • Diagnostics now update after function signature changes. Editing a standalone function's parameter or return type in one file (e.g. changing bar(null $x) to bar(string $x)) did not refresh diagnostics in other open files that call that function, so stale errors persisted until the editor was restarted. The server now tracks function signature changes (not just class signatures) and refreshes affected open files on save, without flashing false errors into unrelated buffers during editing. Same-file diagnostics continue to update on every keystroke. A textDocument/didSave handler was also added as a reliable refresh point for editors like Neovim. Fixes #123. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196.
  • External tool diagnostics (PHPStan, PHPCS, Mago) now run on save only. Previously these expensive tools were scheduled on every keystroke with a debounce timer, which could block save-triggered runs and delay results by seconds. They now fire immediately when a file is opened or saved, with no debounce. External tool workers also send workspace/diagnostic/refresh in pull mode so editors see results without requiring a didChange event. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196.
  • @phpstan-ignore with reasons now suppresses diagnostics immediately. Adding a @phpstan-ignore return.type (reason) comment did not clear the cached PHPStan diagnostic until PHPStan re-ran (~10 seconds). The stale diagnostic filter treated everything between @phpstan-ignore and */ as the identifier list, so the parenthesized reason text caused the match to fail. The parser now strips (reason) from each comma-separated entry before matching, correctly handling per-identifier reasons, multiple identifiers, and reason text containing commas. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196.
  • Literal type matching in argument diagnostics. String, integer, and float literal arguments now match PHPDoc literal-union parameter types. For example, orderBy('id', 'desc') no longer produces a bogus error when the parameter is typed as 'asc'|'desc'. Conversely, passing a provably wrong literal (e.g. 'invalid' to 'asc'|'desc', or 'hello' to numeric-string) is now correctly flagged. Fixes #180. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191.
  • String indexed assignment no longer widens type to array. Bracket-indexed assignment on a string variable ($str[0] = 'z') no longer changes the variable's type from string to array<int, string>. In PHP this operation modifies the string in-place, so the type is now correctly preserved. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/209.
  • mixed no longer behaves like a scalar in array access and member diagnostics. Accessing a key on an array<string, mixed> parameter (e.g. $body['key']) was incorrectly returning an empty type because mixed was treated like a scalar and skipped by the element-type extractor. This caused ternary expressions like true ? $body['key'] : null to resolve as null instead of mixed|null, producing false type-mismatch diagnostics. The same misclassification could also surface unverifiable-member warnings on values typed as mixed. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/210.
  • @see self::member() references in class docblocks now navigate correctly. Docblock @see tags already supported ClassName::member() references, but self::member() was being dropped during docblock symbol extraction, so go-to-definition could not follow it. Class docblocks can now refer to their own methods and members with self::... just like normal PHP code. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/212.
  • Grouped imports resolve correctly across diagnostics and navigation. Grouped use imports now work the same whether they are written on one line or split across multiple lines. Previously, multiline grouped imports could produce false unknown-class diagnostics because only single-line import declarations were skipped by the diagnostic walker, and go-to-definition on a class name inside a grouped use declaration could fail because the grouped item was recorded without its namespace prefix. Imported names inside grouped use declarations are now handled correctly for both unknown-class diagnostics and go-to-definition. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/213.
  • Laravel Conditionable::when() template inference no longer falls back to missing null defaults. Method template binding now avoids inferring template parameters from omitted null defaults except in the few cases where defaults are actually meaningful for template resolution. This fixes false type_mismatch_argument diagnostics on calls like when($request->integer(...), fn ($q, $id) => ...), where the callback parameter type was being collapsed to null instead of the concrete argument type. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/205.
  • declare(strict_types=1) detection. The LSP now reads the declare(strict_types=1) directive from the calling file and tightens argument type checking accordingly. Under strict types, implicit coercions that PHP normally allows in function calls, such as int/float to string and numeric-string to int/float, are flagged as type errors. The int-to-float exception is preserved, concatenation is unaffected, and literal numeric forms now retain their kind during checking instead of being flattened to plain strings. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/193.
  • Type Hierarchy works in more clients. The textDocument/prepareTypeHierarchy capability was registered without registration options, so some clients (notably Zed) did not reliably expose the Type Hierarchy action. The dynamic registration now carries proper TypeHierarchyRegistrationOptions with a PHP document selector, so those clients recognise that the feature applies to PHP files. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/179.
  • Extract method generates correct code for more selections. A variable that the selection reads before it first assigns (for example a parameter the extracted code both consults and updates) is now passed in as an argument as well as returned, instead of being left undefined inside the new method. And an early return whose value references a variable defined inside the selection is now kept inside the extracted method and propagated to the caller, instead of being copied to the call site where that variable does not exist.
  • The editor stays responsive during fast typing in large files. Editors send a burst of requests on every keystroke (completion, a documentation lookup for each suggestion, diagnostics, code lens, semantic highlighting, and more). The server processed only a few at a time, so during continuous typing the burst backed up until the server stopped answering anything at all, including the completion the user was waiting on, and it only recovered after a restart. Now the burst is processed concurrently and every expensive request runs off the main loop: diagnostics (which re-analyze the whole file on each edit) compute in the background instead of on the request that asked for them, so a diagnostic pull returns immediately and never blocks the threads that deliver completion and hover, and repeated whole-file requests (semantic highlighting, code lens, the document outline, folding, document links) are collapsed so a fast typist's superseded requests no longer pile up and monopolize the CPU. Completion and other requests keep coming back while you type.
  • Typing in a large file no longer pegs the CPU and stalls completion. Semantic highlighting recomputed every token's position by rescanning the file from the beginning, so a large file took many seconds at full CPU to highlight. Editors request highlighting on every keystroke, so this ran continuously while typing and starved completion, hover, and other requests until they appeared to hang. Highlighting a large file is now effectively instant, and the same speedup applies to the document outline and code folding, which used the same per-position rescan.
  • The first use of a global helper function no longer stalls. Functions defined in Composer "files" autoload entries and guarded by if (! function_exists(...)) (such as Laravel's app(), session(), and route()) were parsed on demand the first time one was used, which meant the first completion, hover, or go-to-definition involving such a helper blocked while the server parsed every autoload file in turn. These files are now parsed up front during indexing, so the first lookup is instant.
  • Framework global helpers loaded outside Composer autoload are now indexed. Some frameworks ship their global function aliases in a *_global.php file that sits beside an autoloaded helper file but is pulled in by the framework's own bootstrap rather than Composer's files autoload, so it never appears in the autoload manifest. CakePHP is the canonical case: helpers like __(), h(), and env() live in such a sibling and were reported as unknown functions on every call. These sibling helper files are now indexed too, so the globals resolve. Contributed by @dereuromark in https://github.com/PHPantom-dev/phpantom_lsp/pull/175.
  • Classes defined inside conditional blocks are now fully resolved. A class declared inside an if/else version guard (the Doctrine ServiceEntityRepository pattern, where a base class is defined differently per ORM version) was previously discovered by name only, so its parent and @extends generics were dropped. Such classes now carry their full inheritance, so member completion, hover, go-to-definition, and generic type resolution work both on them and inside their own methods. When the same class name appears in more than one branch, the first declaration wins. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/154.
  • Editing a base class stays responsive in large projects. Changing a class that many others extend used to invalidate the resolved-class cache by rescanning every cached class on each edit, which briefly stalled large projects with deep class hierarchies. Invalidation now touches only the classes that actually depend on the edited one.
  • Completion latency stays flat during sustained fast typing. Concurrent requests resolving the same classes contended on a single lock guarding the resolved-class cache, so completion latency crept upward for as long as a typing burst continued. The cache now allows parallel reads, so the many lookups in flight at once no longer serialize behind one another, and when a request first loads a vendor class the work to record it in the shared index is prepared before the index is locked, so other requests no longer wait on it.
  • The server no longer freezes and stops responding. Editors cancel in-flight requests constantly (every cursor move supersedes the previous hover and highlight), and a burst of cancellations, such as when the editor regains focus after being in the background, could wedge the server so that it went completely silent and had to be restarted. Cancelled requests are now handled cleanly.
  • No hang on cyclic class inheritance. Editing a Laravel model that uses a custom Eloquent builder into a temporary state where two classes extend each other (which happens mid-refactor) no longer freezes completion, hover, and diagnostics for that file.
  • Returning to a backgrounded editor stays responsive. When an editor regains focus it re-reports every file in the workspace as changed in one large batch. Processing that batch could stall the server while it re-read thousands of files from disk. The batch is now handled off the main loop and skips files that were never loaded, so the editor stays responsive.
  • A rare internal parser error no longer permanently breaks a file. If analysis of a file hit an unexpected internal error, that file could become unresolvable for the rest of the session, with completion, hover, and go-to-definition silently returning nothing and each attempt stalling briefly. Such errors are now contained and the file recovers the next time it is used.
  • Inherited members no longer briefly flagged as unknown after opening a project. A method or property inherited from a vendor base class (for example the base methods of a framework controller) could be reported as an unknown member right after a file opened, even though hover resolved it correctly, and the error went away when the file was closed and reopened. Such members now resolve as soon as indexing finishes.
  • Named arguments are matched to parameters by name. Calls that pass arguments by name (f(c: 3)) are now bound to the parameters they actually target instead of by their position in the call. Conditional return types resolve correctly when the deciding argument is passed by name out of order, a "missing required argument" error is now reported when a named argument fills an optional parameter but leaves a required one unsupplied, and pass-by-reference type inference seeds the right variable.
  • Argument-count false positives. Extra arguments to a class with no constructor are no longer flagged (PHP accepts them), and namespaced calls to overloaded built-ins written with a leading backslash (\mt_rand()) are no longer measured against the wrong minimum. Immediately invoking the callable returned by a function or method (makeHandler($a, $b)($request)) now checks the inner call's arguments against the returned callable's own signature instead of the outer call's, fixing both false argument-count errors and wrong inlay hint parameter names on the invocation. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191.
  • @var annotations no longer leak between functions. A /** @var T $x */ annotation in one function used to suppress "undefined variable" warnings for that name everywhere in the file; it is now scoped to the function it appears in.
  • PDO fetch methods reflect the fetch mode. PDOStatement::fetch() and fetchAll() now resolve to the type produced by the fetch-mode constant passed to them, so fetch(PDO::FETCH_OBJ) is an object, fetch(PDO::FETCH_ASSOC) is an associative array, and iterating fetchAll(PDO::FETCH_OBJ) yields objects. More generally, conditional return types keyed on a class constant (@return ($mode is Foo::BAR ? ... : ...)) are now evaluated at the call site.
  • Type resolution through chained and untyped access. Null-safe call chains such as $a->b?->c() resolve through the full receiver. Array access on a value of unknown type resolves to mixed, so $x = $arr['key'] ?? 5 no longer produces spurious type errors. foreach element types resolve through interfaces that reach a known iterable several hops away. Nested array-shape narrowing ($a["x"]["y"]) no longer targets the wrong key.
  • self references inside class-level attributes resolve. A self::, static::, or parent:: reference inside an attribute attached to a class (for example #[Route(name: self::ROUTE)]) is now resolved against the class it decorates, so the referenced constant or member is no longer reported as unresolvable.
  • @method tags override inherited methods of the same name. A @method annotation on a class now takes precedence over a method inherited from a more distant ancestor. The common repository pattern, where a base repository declares @method Entity|null findOneBy(...) while its vendor parent returns a generic object, now resolves to the concrete entity type, so members accessed on the result are no longer flagged as unverifiable.
  • ??= keeps the resolved type. After $x ??= new Foo(), the variable resolves to Foo (or the union of its existing non-null type and the assigned value), so property and method access on $x is no longer reported as unresolvable.
  • Generics with fewer arguments than parameters. @extends Collection<User> against Collection<TKey, TValue> now binds User to the value parameter, so inherited element types resolve correctly.
  • Nullable generic return types resolve through inheritance. A method whose native return hint is nullable (object|null) and whose docblock returns a template (@return ?T) now resolves to the bound type, so a repository's find() returns Entity|null instead of the bare object|null. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/152.
  • Conditional is null return types resolve consistently regardless of how the call site is parsed, and an explicitly passed null now selects the null branch.
  • Go-to-definition, rename, and highlight accuracy. References in @see tags to qualified names like App\Foo::bar() now land on the correct location, and renaming a property selects the whole $name instead of $nam.
  • @phpstan-require-extends and @phpstan-require-implements navigation. Class and interface names in these trait constraint tags now support go-to-definition and hover, and an import used only by such a tag is no longer flagged as unused. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/172.
  • Renaming variables captured by nested closures and arrow functions. Renaming or finding references to a variable used inside deeply nested arrow functions (fn () => fn () => $var) or closures with use ($var) now updates every occurrence, whether the rename is triggered on the declaration or from deep inside the nesting. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/145.
  • Variables inside dynamic property accesses are tracked. A variable used as a dynamic property selector ($message->{$attribute}) now counts as a use, so it is no longer wrongly reported as unused, find-references includes it, and renaming the variable updates the selector along with its other occurrences. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/174.
  • Member rename stays scoped to the declaration it targets. Renaming a method or property no longer touches same-named members on unrelated classes. A private method rename updates only that method and its real usages, calls on a receiver whose type cannot be resolved are left alone, renaming one implementation of an interface no longer renames sibling implementations, and renaming a child override stays on the child branch. Renaming a parent or interface declaration still updates the inherited overrides and implemented usages. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/160.
  • Find references on a constructor lists every call site. Finding references to a __construct declaration now reports the new ClassName(...) instantiations, #[ClassName(...)] attribute usages, and explicit delegation calls written as parent::__construct(), self::__construct(), or Class::__construct(), including for subclasses that inherit the constructor (and excluding subclasses that override it). Attribute classes that are never written as new are now found. Contributed by @RemcoSmitsDev in https://github.com/PHPantom-dev/phpantom_lsp/pull/155.
  • Positions on lines with multibyte characters. Signature help, go-to-definition on virtual properties, named-argument completion, unused-import removal, and the @phpstan-ignore quickfix placed cursors and edits at the wrong column on lines containing multibyte characters; they now use the correct UTF-16 columns. Type strings containing * wildcards or variance annotations are also no longer mangled.
  • Unused-import hint location. When two imports share a name prefix (use App\Foo; and use App\FooBar;), the "unused import" dimming now lands on the correct statement.
  • Document outline ranges. Methods, properties, constants, and functions in the outline and breadcrumbs now report a range covering the whole declaration, with the name nested inside, as editors expect for folding and breadcrumb extent.
  • Stale vendor symbols after composer update. Functions and constants removed from the vendor tree are now purged from the indexes, so completion and go-to-definition stop offering symbols that no longer exist.
  • Type hierarchy locates the class name even when the class keyword and the name are on separate lines.
  • Edits on Windows (CRLF) files land correctly. Rename, remove-unused-import, and the PHPStan return-type quickfix computed line offsets assuming single-byte line endings, so on files with \r\n terminators the edits drifted one byte per preceding line and could corrupt the file. Offsets now account for the real terminator.
  • Malformed @method tags no longer crash requests. A docblock with a degenerate @method signature (such as @method >()) could panic completion, hover, and go-to-definition. Such tags are now parsed gracefully and simply produce no virtual method.
  • Code lens navigation. Code lenses now work in Zed, Neovim, Emacs, and other editors. Previously the click command used a VS Code-specific API that other editors ignored.
  • @mixin with union types. @mixin Foo|Bar now correctly exposes members from all classes in the union. Previously only single-class mixins were recognized.
  • throw new and catch completion behave like new. Interfaces, abstract classes, traits, and enums are filtered out of throw new completion, which now offers only Throwable descendants, matching new. Completion inside catch() and @throws now applies the same ranking, FQN shortening via use statements, namespace drill-down, and deprecation styling as the other class-name completion contexts.
  • Analysis deadlock. Lazily-parsed vendor files acquired two internal locks in the opposite order from the editor's file-change handler, causing a deadlock when both ran concurrently.
  • External tool diagnostics on large files. PHPStan, Mago, and PHPCS diagnostics no longer time out on files that produce a large report. Their output is now read while the tool is still running, so a report bigger than the operating system's pipe buffer can no longer stall the tool and force a timeout.
  • Promote to constructor property. Promoting a parameter whose property is declared together with others on one line (private int $a, $b;) no longer deletes the sibling properties. The action is now offered only when the property is declared on its own.
  • get_defined_vars() counts as using every variable in scope. A function or method that calls get_defined_vars() (for example to build a debug dump) no longer reports its local variables as unused, since the call reads all of them. Variables local to a nested closure or arrow function are still checked. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/158.
  • Integer literals now satisfy named refined-int parameter types. A literal like 1 passed to a positive-int or non-negative-int parameter no longer produces a false type_mismatch_argument, matching the existing behaviour for int<min,max> ranges. Passing a literal that genuinely violates the refinement (e.g. 0 to positive-int, or a negative literal to non-negative-int) is now correctly flagged. non-zero-int and callable-string-family PHPDoc types, which previously failed to parse and were silently ignored by these checks, are now recognized as well.
  • PHPStan's __benevolent<T> wrapper type is recognized. A docblock type like @var __benevolent<Foo|null> now resolves as its inner type instead of reporting a false "class not found" on the wrapper.
  • Indexing an object implementing ArrayAccess resolves through offsetGet. $obj[$key] on a class implementing ArrayAccess now resolves to the value type declared in a generic annotation (@implements ArrayAccess<TKey, TValue>), falling back to offsetGet()'s own declared return type when no annotation is present, mirroring how foreach already fell back to Iterator::current(). This also fixes a class's own @template parameter resolving to its declared bound instead of leaking through as an unrelated type name when referenced directly in that same class's @implements/@extends annotations.
  • Reassigning a variable using its own previous value resolves the reference correctly. In $x = f(fn() => ..., $x), the $x read inside the right-hand side now resolves to its type before the reassignment rather than the reassignment's result, so a self-referencing statement like $items = implode(', ', array_map($fn, $items)) no longer reports a spurious argument type mismatch on the reused variable.
  • PHPDoc tags indented with extra spaces after the asterisk are honored. A tag written as * @param (two or more spaces between the asterisk and the tag, a common style in vendor code) was previously ignored entirely. Every such tag now parses the same as the single-space form, so @phpstan-type and @phpstan-import-type aliases are recognized rather than treated as class names, and @param, @return, and @var types written this way take effect. A parameter typed with an imported type alias no longer reports a spurious argument mismatch against the passed value, and the alias name is no longer flagged as an unknown class.
  • Mockery shouldHaveReceived() / shouldHaveBeenCalled() verification chains resolve. These are declared as returning self, but Mockery actually returns a verification director object that exposes with(), withArgs(), once(), and similar chained assertions. Chaining onto the result ($mock->shouldHaveReceived('store')->with(...)->once()) no longer reports the chained call as missing.
  • A leading-backslash type resolves to the global class even when a same-named class is imported. A variable typed \Redis (via @var or elsewhere) now resolves to the global \Redis class regardless of a use SomeNamespace\Redis; import that shares the short name, so its members complete, navigate, and type-check instead of resolving to the imported class.
  • HTML lists in docblock descriptions render on hover. Descriptions written with HTML markup, including bulleted and numbered lists, now appear as formatted Markdown in hover popups instead of showing raw tags or losing their structure entirely. Contributed by @calebdw.
  • Memory no longer grows for the whole session as files are closed. Closing a file now releases the parse errors held for it, so a long editing session that opens and closes many files no longer accumulates their state until restart.
  • Method completion no longer inserts a duplicate pair of parentheses. Typing a method name to completion and then typing ( yourself, instead of accepting the suggestion with Enter or Tab, no longer leaves behind an extra (). The suggestion already inserts the call's parentheses (and argument placeholders), so treating ( as a separate auto-accept trigger produced two pairs.

0.8.0 - 2026-05-14

Added

  • Blade template support. Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside .blade.php files. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/100.
  • Blade keyword highlighting. Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside .blade.php files now receive semantic tokens for proper syntax coloring.
  • Blade view directive navigation. Go-to-definition works on view names inside Blade directives (@include, @extends, @includeIf, @includeWhen, @includeUnless, @includeFirst, @component, @each), jumping to the referenced template file.
  • Replace FQCN with import. A refactoring code action on any fully-qualified class name (\Foo\Bar) inserts a use statement and replaces all occurrences of the same FQCN throughout the file with the short name. Detects existing imports and short-name conflicts. A separate "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts).
  • Broader type narrowing. instanceof, type-guard functions, in_array() strict mode, assert(), @phpstan-assert-if-true/-if-false, and compound &&/|| conditions now narrow types in if/else branches, guard clauses, while-loop bodies, ternary expressions, and match(true) arms.
  • Argument type mismatch diagnostics. Flags function and method calls where an argument's resolved type is incompatible with the declared parameter type.
  • Invalid class-like kind diagnostics. Flags class-like names used in positions where their kind is guaranteed to fail at runtime: new on abstract classes, interfaces, traits, or enums; extends on a final class, interface, or trait; implements with a non-interface; trait use with a non-trait; instanceof with a trait; catch with a non-Throwable type; and traits in type-hint positions.
  • Unused variable diagnostics. Variables assigned but never read are flagged with hint severity and rendered as dimmed text. Variables named $_ or prefixed with $_ are exempt.
  • Mago diagnostic proxy. Mago lint and analyze diagnostics are surfaced as LSP diagnostics with quick-fix code actions. Configurable under [mago] in .phpantom.toml.
  • Laravel Pint formatting. Projects with laravel/pint in require-dev automatically use Pint for formatting via stdin. Configurable under [formatting] in .phpantom.toml with pint = "path" or pint = "" to disable.
  • PHPCS diagnostic proxy. PHP_CodeSniffer violations are surfaced as LSP diagnostics with severity mapping. Configurable under [phpcs] in .phpantom.toml.
  • Return type inference from method bodies. Methods without a declared return type or @return docblock now have their return type inferred from return statements, improving completion, hover, and diagnostics for untyped code.
  • Closure and arrow function parameter inference. Untyped closure parameters are inferred from the enclosing call's callable signature, including through method chains that return static. Generic type substitution flows through to inferred parameters.
  • Closure and arrow function inlay hints. When a closure or arrow function is passed to a callable-typed parameter, inlay hints show inferred parameter types and the return type derived from the enclosing callable signature.
  • Generics. @mixin tags referencing a template parameter now resolve through the template bound. new $var() where $var is class-string<T> resolves to T. SPL collection classes now carry @template parameters so iteration methods resolve to concrete type arguments.
  • Namespace renaming. Renaming a namespace segment updates all declarations, use statements, and fully-qualified references across the workspace. When a PSR-4 autoload mapping exists, the corresponding directory is moved automatically.
  • Linked editing ranges. Place the cursor on a variable and all occurrences within its scope enter linked editing mode, updating every occurrence as you type.
  • Import all missing classes. A bulk code action that imports every unresolved class name in the file at once. Ambiguous names are left for manual resolution.
  • Context-aware import candidate filtering. Import class actions now filter candidates by syntactic context (only interfaces after implements, only traits after use, etc.).
  • Convert to instance variable. A code action that promotes a local variable inside a method to a class property, rewriting all references to $this->prop (or self::$prop in static methods).
  • Laravel view, route, and translation key navigation. Go to Definition works for Blade view names (view('...')), route names (route('...')), and translation keys (__('...'), trans(...), Lang::get(...)). Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/101.
  • Laravel config and env key navigation. Go to Definition and Find All References work for config keys and env variables (config('app.name'), env('APP_KEY')). Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/93.
  • Untyped property type inference from constructor. Properties without type declarations are resolved by inspecting the constructor body for assignments and promoted parameter defaults. Contributed by @lucasacoutinho in https://github.com/PHPantom-dev/phpantom_lsp/pull/81.
  • Binary expression type inference. Hover and variable resolution now show result types for all binary operators (int + intint, int + floatfloat, int / intint|float). Compound assignments update the variable's type accordingly.
  • Nested array shape inference from multi-level key assignments. Assignments like $b['a']['b'] = 'x' now produce a nested array shape type (array{a: array{b: string}}), enabling array key completion for incrementally built arrays.
  • Loop type propagation. Variables assigned late in loop bodies are now visible from the start on subsequent iterations.
  • global keyword variable resolution. Variables imported with global $var now resolve to their top-level type, enabling completion, hover, and go-to-definition.
  • array_reduce, array_sum, and array_product return type inference. array_reduce() resolves to the type of its initial value argument. array_sum() and array_product() resolve to int|float.
  • Machine-readable CLI output. Both analyze and fix accept a --format flag with table, github, and json options. When GITHUB_ACTIONS is set, table output automatically includes GitHub annotations.
  • Magic property diagnostics. New report-magic-properties option under [diagnostics] in .phpantom.toml. When enabled, classes with __get that also have virtual properties (from @property docblock tags, Laravel Eloquent column inference, or other providers) will flag unknown property access instead of silently allowing it.
  • Inline diagnostic suppression. // @phpantom-ignore code on the same line or the line above suppresses the specified diagnostic. Multiple codes can be comma-separated. A bare // @phpantom-ignore suppresses all diagnostics on the target line.
  • Find references and rename for PHPDoc virtual members. @property, @property-read, @property-write, and @method declarations in docblocks are now included in find-references and rename results alongside their runtime usages, including when the subject has a nullable or union type (e.g. Foo|null from ->first()). Contributed by @AbyssWaIker in https://github.com/PHPantom-dev/phpantom_lsp/pull/115.

Changed

  • Find References performance and freshness. Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in https://github.com/PHPantom-dev/phpantom_lsp/pull/116.
  • Incremental text sync. The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke.
  • LSP responsiveness. Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations.
  • Faster analysis. Analysis time cut significantly on large projects.
  • Reduced redundant file parsing. Concurrent threads resolving the same vendor class no longer parse the file in parallel; the second thread waits for the first to finish.
  • Unified first-class callable resolution. First-class callable return type inference ($fn = $obj->method(...)) now uses the shared call return type pipeline, improving accuracy for chained calls and generic substitutions.
  • Editing responsiveness. Classes evicted from the cache after a file edit are now eagerly re-populated in dependency order.
  • Diagnostic delivery model. Editors that support pull diagnostics now get diagnostics on first file open without waiting for a debounce timer. Updates from external tools no longer re-run the entire native diagnostic pipeline.
  • Virtual member resolution. Mixins and virtual accessors are now resolved completely on every class, eliminating cases where they were missing after edits.
  • Diagnostic code identifiers. All diagnostic codes now use a consistent snake_case noun-phrase scheme: unknown_variable, type_mismatch_argument, argument_count_mismatch, deprecated_usage, missing_implementation. Users with editor filters matching on these codes will need to update them.
  • Lower memory usage for lazily-loaded files. Vendor and stub files no longer store per-file import tables and namespace maps after parsing, and go-to-implementation uses a dedicated reverse-inheritance index instead of scanning all parsed files.
  • Lower memory usage for variable type tracking.
  • Faster variable name completion. Variable name suggestions now use the precomputed symbol map instead of re-parsing the file. Foreach iteration variables correctly persist after the loop (matching PHP semantics), @var docblock variable names are included, and unset() removes variables from suggestions.
  • Faster go-to-definition for variables. Variable definition lookup no longer re-parses the file as a fallback; the precomputed symbol map handles all cases.
  • Updated embedded phpstorm-stubs.

Fixed

  • throw new completion missing vendor classes. Classes whose Throwable ancestry could not be immediately verified (e.g. vendor classes not yet parsed) were silently excluded from throw new and catch completion, even though later heuristic-based sections should have included them.
  • Stale mixin members after editing. Mixin class resolution (e.g. @mixin Builder) is now invalidated when any file changes, so newly added or removed methods on mixin targets appear immediately without restarting the server.
  • Version-gated stub constants now filtered. Constants with @removed tags (e.g. MCRYPT_ENCRYPT, removed in PHP 7.2) are now excluded from completion and resolution when the project targets a newer PHP version. Previously only classes and functions were filtered.
  • Go-to-definition. Fixed a potential deadlock when navigating to a vendor class that hadn't been parsed yet.
  • LSP no longer freezes under heavy editor activity. Server-to-client requests (diagnostic refresh, progress token creation) could deadlock the service loop when the editor was simultaneously sending bursts of open/close/hover messages. All server-to-client requests are now either fire-and-forget or time-bounded, long-running handlers are cancellation-safe, and the process exits cleanly if the service loop ever terminates unexpectedly.
  • Rename class preserves self, static, and parent keywords. Renaming a class no longer replaces occurrences of self::, static::, or parent:: with the new class name.
  • Rename propagates into closures and arrow functions. Renaming a variable now follows explicit use ($var) captures into closure bodies and implicit captures into arrow function bodies, instead of leaving those occurrences unchanged.
  • Spurious function auto-imports. Import statements like use function is_array; were misidentified as function declarations, polluting the completion list with phantom entries that inserted incorrect imports.
  • Duplicate use function insertion. Accepting a function completion no longer inserts a use function statement when the exact import already exists in the file.
  • Function import conflict handling. When a different function with the same short name is already imported, completing a namespaced function now inserts the fully-qualified name instead of the ambiguous short name.
  • False-positive unused variable diagnostics. Variables passed to compact(), by-reference out-parameters (e.g. preg_match($p, $s, $matches)), and variables used only via global are no longer incorrectly flagged.
  • False-positive type mismatch diagnostics. Bare array return values passed to typed array parameters, properties narrowed via instanceof, type alias parameters, and use-map shadowing no longer trigger incorrect type errors.
  • Functions inside if (!function_exists(...)) guards. Function bodies nested inside conditional blocks no longer produce false-positive unresolved-member-access errors.
  • Standalone @var completion. Variables typed only via a standalone /** @var Type $var */ docblock now resolve for member completion and go-to-definition.
  • @var docblocks with additional tags. Extra tags like @psalm-suppress in the same docblock no longer corrupt the type string.
  • Foreach @var annotations for key and value variables. Multi-line docblocks with multiple @var tags before a foreach now correctly override both key and value types.
  • Foreach element type from untyped arrays. Variables in a foreach over bare array now resolve to mixed instead of empty.
  • Foreach narrowing with break in else. The variable state from break paths is now included in the post-loop type.
  • Foreach target type after non-empty literal array. The pre-loop sentinel value no longer survives as a possible post-loop type.
  • Foreach over ::class literal arrays resolves static access. $className::CONST and $className::method() no longer produce unresolved-member diagnostics.
  • Hover on reassigned variable shows post-assignment type. Hovering on the left-hand side of a reassignment now shows the type produced by the assignment.
  • Multi-namespace class resolution. Short class names now resolve against the correct namespace for the current scope.
  • Multi-namespace variable isolation. Variable resolution now only considers the namespace block containing the cursor.
  • Multi-namespace function return type resolution. Function return types are now resolved against the function's own namespace.
  • Multi-namespace static call class resolution. ClassName::method() now resolves against the correct namespace block.
  • Short class name resolution in type hints. The resolver now prefers the class in the same namespace as the owning type before falling back to first-match.
  • Class loader global fallback. Unqualified class names in namespaced code now fall back to global scope lookup when the namespace-qualified name doesn't exist.
  • Template inference through stub interfaces. @template-implements on stub-loaded interfaces now correctly propagates substituted return types to child methods.
  • Generic method return types from @var annotations. Method calls on variables annotated with a generic type now correctly substitute class-level template parameters into the return type.
  • Template union inference from multiple arguments. When multiple arguments bind to the same @template T, the resolved type is now the union of all inferred types instead of only the first.
  • Template param inference from type bounds. Nested template params are now inferred from concrete generic arguments when a template parameter has a generic bound.
  • Method-level @template with key-of bound. Passing a string literal to a method with @template K as key-of<TData> now resolves the return type to the specific array shape value type.
  • __get magic method template resolution. Property access on a class whose __get uses key-of<T> bounds now infers the concrete type from the property name.
  • Magic __get property access. Accessing undefined properties on objects with a __get method now resolves to the method's declared return type.
  • Magic __call method return type. Calling undefined methods on objects with a __call method now resolves to __call's declared return type.
  • SoapClient arbitrary methods. Calling any method on SoapClient no longer produces false-positive "unknown member" diagnostics.
  • Literal true/false preserved in template inference. Passing true or false to a generic constructor now keeps the precise type instead of widening to bool.
  • @psalm-method overrides @method. The vendor-prefixed tag now takes priority when both are present.
  • @psalm-param/@phpstan-param priority over @param. @phpstan-param takes precedence over @psalm-param, which takes precedence over @param, matching PHPStan and Psalm behaviour.
  • @psalm-if-this-is template inference. Method-level template parameters are now inferred by matching the receiver's concrete type against the annotation's type pattern.
  • self::class and static::class in template arguments. Passing these to a class-string<T> parameter now correctly resolves T to the enclosing class.
  • static return type through first-class callables. self::method(...)() and similar patterns now preserve static in the return type.
  • Interface method return type inheritance. Template-substituted return types from interfaces are now propagated to overriding methods without a return type.
  • Property self/static type resolution. Properties with @var self|null or static now resolve to the owning class name in hover.
  • Trait self return type resolution through inheritance. Trait methods with return type self now resolve to the declaring class, not the calling subclass.
  • Conditional return type resolution for scalar arguments. $param is string conditions in @return annotations now resolve correctly for literal values.
  • SPL iterator generic type propagation. Decorator iterators like CachingIterator and LimitIterator now propagate the wrapped iterator's generic type parameters.
  • ArrayIterator constructor generic inference. new ArrayIterator($typedArray) now infers key and value types from the array argument.
  • range() return type inference. range() now returns list<string> for string arguments and list<int|float> otherwise, instead of bare array.
  • (object) cast type inference. Casting now resolves to an object shape matching the operand's structure instead of bare stdClass.
  • ArrayAccess array-access assignment. $obj[$key] = $val on ArrayAccess objects no longer overwrites the variable's generic type with an array type.
  • Static method calls on class-string unions. $variable::method() where $variable holds a union of class-strings now resolves through all possible classes.
  • Array shape keys with special characters. Keys containing backslashes or newlines are now properly quoted and escaped in type display.
  • Implement methods: no invalid generic return type hints. The "Implement missing methods" code action no longer emits generic docblock syntax as a native PHP return type hint.
  • Composer files autoload packages now indexed. Vendor packages using "autoload": {"files": [...]} now have their classes discovered correctly.
  • Classmap collision resolution. When two files declare the same class name, the file matching PSR-4 naming convention is now preferred.
  • Eloquent $dates and where{Property} go-to-definition. Go-to-definition now works for properties backed by the $dates array and dynamic where{Property}() methods.
  • Type hierarchy registration. Dynamic registration is now gated on client capability, preventing errors in unsupported editors.
  • False-positive diagnostics on startup. Files opened while the project was still indexing could produce spurious "class not found" errors. Diagnostics are now deferred until initialization completes.
  • Analyzer and LSP no longer hang on files with deeply nested loops.
  • Infinite loop on array key reassignment patterns. Files containing $arr['key'] = f($arr['key']) no longer hang the analyzer.
  • Chained calls with complex arguments resolve the correct return type. Calling redirect($string . $var)->with(...) now resolves to RedirectResponse as expected. Complex argument expressions (concatenation, method calls, etc.) were previously serialized as empty, causing conditional return types to take the wrong branch.
  • Stack overflow on large codebases and large files. The analyze command no longer crashes with stack overflows on large files.
  • Non-deterministic diagnostic counts eliminated. Projects with heavy use of generics no longer see false positives that vary between runs.
  • Pull-diagnostic reliability. Editors that support pull diagnostics no longer show duplicate or stale diagnostics.
  • Hover scales linearly on large files. Hover requests no longer take O(n²) time on files with many method calls.
  • analyze and fix commands run at consistent speed regardless of invocation style.
  • Type narrowing. Comprehensive fixes: is_*() guards correctly narrow multi-member unions; instanceof on mixed or object narrows to the checked type; === null and == null narrow correctly; assert() narrowing persists through subsequent branches; isset()/empty() strip null from nullable types; property access expressions are narrowed through conditionals; array shape keys are narrowed through guard clauses; OR'd instanceof checks resolve to the union of all branches; post-loop narrowing applies the loop condition's inverse; branch merging preserves nullable information correctly.
  • Generics. Constructor generic inference works through inherited constructors with correct remapping through multi-level @extends chains. Function-level templates are inferred from arguments extending wrapper classes. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound when subclasses omit annotations. Method calls on unions of generic types resolve to the union of each branch's return type. key-of<T>, value-of<T>, and indexed access types evaluate to concrete types after template substitution. Array literal arguments infer key and value types separately.
  • Mixin resolution. Static method calls on instances with @mixin now resolve through the mixin. @method and @property tags on mixin classes are propagated to the consumer. $this return types on mixin methods resolve to the consumer class.
  • @method tag resolution. Colon return type syntax, parenthesised return types, and the ambiguous single-static pattern are now parsed correctly. Template parameters in @method return types are substituted through @extends and @implements annotations.
  • First-class callable invocation return types. Immediately invoking a first-class callable (Foo::method(...)()) now resolves to the underlying function's return type.
  • Chained instantiation preserves constructor-inferred generics. Expressions like (new Box(new Product()))->get() now propagate template arguments to subsequent method calls.
  • @return numeric pseudo-type. Functions annotated with @return numeric now resolve correctly instead of falling back to string.
  • parent::__construct() with @extends generics. No longer produces false-positive type errors for substituted parameter types.
  • Array access on bare array and mixed types. Accessing a key on plain array now resolves to mixed instead of an empty type.
  • Vendor functions and constants. Functions and constants defined in vendor packages are now indexed at startup, eliminating false-positive diagnostics.
  • Use-imported classes no longer shadowed by global-namespace stubs. Fixes Laravel Facade static method resolution.
  • Same-name class in a different namespace no longer shadows inherited members.
  • Short-name collisions eliminated project-wide. Two unrelated classes sharing a short name are no longer treated as identical.
  • Transitive interface inheritance. A class implementing an interface that extends another interface is now correctly recognized as a subtype of the parent interface.
  • Conditional return types. Methods with conditional return types now check whether the argument class implements the bound interface, and class names in conditional annotations are resolved through the defining file's use statements.
  • Promoted properties. Inline /** @var */ annotations on promoted constructor properties now resolve inside the constructor body.
  • Backed enums. Accessing ->value resolves to the specific backing type. @implements generics on enums are resolved correctly.
  • Class constants. Inherited constants accessed via self::CONST or ChildClass::CONST resolve through multi-level inheritance.
  • Hover / type display. T[] displays as array<T>, mixed[] as array. PHPDoc type aliases are normalized. Methods returning parent resolve to the actual parent class name.
  • Chain assignments. $a = $b = new Foo() resolves all variables in the chain.
  • Destructuring. Array destructuring ([$a, $b] = $expr, list(), keyed shapes, nested patterns) and foreach destructuring now resolve types correctly.
  • Variable type resolution. Short class names from @var, @param, and new ClassName() are resolved to FQN before entering the type pipeline.
  • Closure inlay hints. Template parameters in callable signatures are substituted with concrete types inferred from sibling arguments.
  • Laravel scopes. Public methods with the #[Scope] attribute are no longer treated as scopes.
  • Static methods. $this no longer resolves inside static methods.
  • Hover cache invalidation. Editing a cross-file class's docblock now immediately reflects updated content on hover.
  • Foreach type resolution. Nested generic array access, static property iterables, type alias expansion, and by-reference bindings all resolve element types correctly. Loop prescan no longer leaks types into the same-statement RHS.
  • Completion in loops and branches. Array shape keys added inside if blocks, variables assigned later in loop bodies, and variables on the RHS of reassignments all resolve correctly.
  • Scope leakage after closures in chained method calls. Variables from the enclosing method are no longer invisible after a closure argument.
  • Docblock @param annotations no longer leak across sibling methods or closures.
  • class-string<T> parameter completion. Parameters typed as class-string<T> resolve to the bound class for member access.
  • Inherited parameter types propagate to child methods.
  • False positive type error for closures passed to callable parameters. \Closure is now recognised as a subtype of callable.
  • Union-typed method calls no longer lose resolution on second occurrence.
  • Fluent method chains in namespaced classes. Methods returning static or self resolve correctly across namespaces.
  • False-positive undefined variable diagnostics. By-reference parameters, nested array access assignments, and $this-prefixed variable names no longer produce false positives.
  • Auto-import formatting. Missing blank line before first import and bulk "remove unused imports" in braced namespaces are fixed.
  • Exception types in catch clauses matched correctly across namespaces.
  • Nested match(true) expressions no longer produce incorrect diagnostics.
  • Lowercase built-in class names recognized as subtypes of object.
  • False "class not found" for global-namespace classes loaded via Composer's files autoloading.
  • False-positive type errors on generic class methods. Template parameters are now substituted into method parameter types before checking argument compatibility.

0.7.0 - 2026-04-08

Added

  • @psalm-return, @psalm-param, and @psalm-var tag support. Psalm-prefixed docblock tags are now recognized alongside their PHPStan equivalents for return types, parameter types, variable types, conditional return types, template parameter bindings, and semantic token highlighting.
  • Refactoring code actions. Extract function, extract method, extract variable, extract constant, inline variable, promote constructor parameter, generate constructor (traditional and promoted), generate getter/setter, and generate property hooks (PHP 8.4+). Deferred computation ensures the lightbulb menu appears instantly; edit generation only runs when the user picks an action.
  • PHPStan quickfixes. Automated fixes for a wide range of PHPStan diagnostics: update or remove mismatched @return/@param/@var tags, remove unused return type union members, fix unsafe new static() (add @phpstan-consistent-constructor, final class, or final constructor), add or remove #[Override], add #[\ReturnTypeWillChange], fix void return mismatches, add inferred iterable return types, remove unreachable statements, remove always-true assert() calls, fix overriding member visibility, fix vendor-prefixed class names, and simplify ternary expressions to ?? or ?->. All quickfixes eagerly clear their diagnostic on apply.
  • fix CLI subcommand. phpantom_lsp fix applies automated code fixes across a project. Specify rules with --rule (multiple allowed) or omit to run all preferred fixers. --dry-run reports what would change without writing files. The first shipped rule, unused_import, removes unused use statements project-wide, collapsing blank lines left behind by removals (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/54). Supports path filtering and single-file mode.
  • Keyword completions. Context-aware PHP keyword suggestions filtered by scope (e.g. return only inside functions, break only inside loops, member keywords inside class bodies, enum backing types after enum Name:). Contributed by @ryangjchandler in https://github.com/PHPantom-dev/phpantom_lsp/pull/43.
  • Attribute completion. Typing inside #[…] offers only classes decorated with #[\Attribute], filtered by the target declaration kind.
  • Eloquent model enhancements. Timestamp properties (created_at, updated_at) are automatically typed as Carbon with support for $timestamps = false and custom column constants. Legacy $dates arrays produce typed virtual properties. $appends entries produce virtual properties. where{PropertyName}() dynamic methods are synthesized from all known columns (including @property annotations) on both the model and the Builder. whereHas/whereDoesntHave closure parameters resolve to Builder<RelatedModel> by traversing relationship methods, with dot-notation chain support. Conditionable::when()/unless() chains preserve type information.
  • Type-guard narrowing. is_array(), is_string(), is_int(), is_float(), is_bool(), is_object(), is_numeric(), and is_callable() narrow union types inside if/else/elseif bodies and after guard clauses, preserving generic element types through narrowing.
  • Array value type tracking. Arrays built incrementally with variable keys inside loops now carry element types through foreach iteration, bracket access, and null-coalescing. Foreach over generic arrays with non-class element types (array shapes, scalars) now preserves the full element type.
  • Inherited docblock type propagation. When a child class overrides a method without providing its own @return or @param docblock, the ancestor's richer types flow through automatically. Applies to return types, parameter types (matched by position), property type hints, and descriptions.
  • Bidirectional template inference from closures. Templates appearing in callable parameter signatures are now inferred from both the closure's return type and its parameter types. Positional matching is supported, and return-type bindings take priority when the same template appears in both positions.
  • Drupal project support. Drupal projects are detected via composer.json. Drupal-specific directories and PHP extensions (.module, .install, .theme, .profile, .inc, .engine) are recognized and indexed. Contributed by @syntlyx in https://github.com/PHPantom-dev/phpantom_lsp/pull/52.
  • Completion and signature help for new self, new static, and new parent. Constructor parameter snippets and signature help inside the parentheses. Contributed by @RemcoSmitsDev in https://github.com/PHPantom-dev/phpantom_lsp/pull/51.
  • Hover on parameter variables at their definition site. Hovering on a function or method parameter now shows its resolved type, using the @param docblock type when it is richer than the native hint. Contributed by @RemcoSmitsDev in https://github.com/PHPantom-dev/phpantom_lsp/pull/68.
  • Array element type extraction from property generics. Bracket access on properties annotated with generic array or collection types (e.g. $this->cache[$key]->) now resolves the element type correctly through nested chains, string-literal keys, and method chains after the bracket.
  • @phpstan-assert-if-true $this narrowing. Instance methods annotated with @phpstan-assert-if-true or @phpstan-assert-if-false targeting $this now narrow the receiver variable in the corresponding branch. Contributed by @syntlyx in https://github.com/PHPantom-dev/phpantom_lsp/pull/52.
  • Namespace completion from file path. When creating a new PHP file, typing namespace suggests the correct namespace inferred from the file's location and the project's PSR-4 autoload mappings. The most specific mapping is preselected so you can accept it with a single keypress. When multiple PSR-4 roots match the same directory, all candidates appear ranked by specificity (longest match first).
  • Standalone @var docblock for untyped closure parameters. When a closure parameter lacks a type hint and no assignment follows, a @var block above the usage is now picked up as the variable's type.
  • --stdio CLI flag. Accepted (and ignored) for compatibility with LSP client wrappers that pass --stdio by default. Contributed by @markkimsal in https://github.com/PHPantom-dev/phpantom_lsp/pull/67.
  • --tcp CLI flag. phpantom_lsp --tcp 9257 starts the server listening on a TCP port instead of stdin/stdout. Useful for debugging or connecting from IDE plugins that prefer a network transport over spawning a child process. Accepts a full address (127.0.0.1:9257) or just a port number. The server accepts one connection and exits when the client disconnects.
  • Zed extension setup instructions. Contributed by @daronspence in https://github.com/PHPantom-dev/phpantom_lsp/pull/47.
  • SETUP.md improvements. Contributed by @mattsches in https://github.com/PHPantom-dev/phpantom_lsp/pull/61.
  • Method-level template parameters resolve inside method bodies. @template T of Builder with @param T $query now resolves $query to the template bound inside the method body, providing completions from the bound class.
  • Undefined variable diagnostic. Variable reads that have no prior definition (assignment, parameter, foreach binding, catch variable, global, static, use() clause, or destructuring) in the same scope are flagged as errors. Writes must appear before the read in source order, catching use-before-assign bugs, while assignments inside branches (if/else, switch, try/catch) still count to avoid false positives. Suppressed for superglobals, isset()/empty() guards, compact() references, extract() calls, variable variables ($$), @ error suppression, and @var annotations. Static property accesses (self::$prop, static::$prop, parent::$prop) are excluded. Variables passed to by-reference parameters are recognized as definitions: 40+ built-in PHP functions are covered (regex, cURL, OpenSSL, sockets, DNS, etc.), and user-defined functions, static methods, and constructors with &$param parameters are detected automatically from their signatures. Scoping is tracked through arbitrary nesting of closures, arrow functions, and catch blocks. Top-level code outside functions is skipped.
  • By-reference parameter type inference for method, static, and constructor calls. When a variable is passed to a by-reference parameter with a type hint (e.g. function foo(Baz &$bar)), the variable acquires that type after the call. Previously this only worked for standalone function calls. Now it also works for $this->method(), static method calls, and constructor calls.

Changed

  • Fewer false-positive diagnostics. Variable resolution now produces the same result across completions, hover, and diagnostics, eliminating cases where diagnostics disagreed about a variable's type.
  • @phpstan-ignore is never the preferred quickfix. The "Ignore PHPStan error" code action is explicitly non-preferred, so editor keyboard shortcuts no longer accidentally apply it when another fix is available.
  • Generate PHPDoc infers @return from the function body. Typing /** above a function that returns array now produces a specific element type (e.g. @return list<string>) instead of @return array<mixed>.
  • Faster startup. Stub loading during initialization is significantly faster.
  • More accurate generics resolution. Type substitution and resolution for complex nested generic types is more correct, particularly for unions, intersections, array shapes, and deeply nested generic arguments.
  • More accurate type predicates. NULL, Null, and case variants of null are now handled consistently throughout type checking, matching PHP's case-insensitive treatment of type keywords.
  • Go-to-definition at declaration sites returns the symbol's own location. Class, member, and variable declaration names now return their own location instead of nothing, so editors that detect "definition == cursor" can automatically fall back to Find References. Contributed by @lucasacoutinho in https://github.com/PHPantom-dev/phpantom_lsp/pull/76.

Fixed

  • Completion no longer triggers on the <?php open tag. Typing <?php and pressing enter no longer applies a spurious function suggestion like php_ini_loaded_file().
  • Case-insensitive parent handling in chained static calls. resolve_lhs_to_class now handles parent::method(...) in chained callable expressions and uses case-insensitive matching for self/static in the same context.
  • Intersection types preserved through resolution. Variables and parameters with intersection types (e.g. Countable&Serializable) now display correctly in hover, extract-function parameter hints, and generated docblocks. Previously intersection types were flattened to unions (Countable|Serializable).
  • Return types now carry class info through the resolution pipeline. Method and function return types that name a class (e.g. Collection<User>) now populate the resolved class info eagerly, so downstream consumers (hover, narrowing, completion) no longer need a second resolution pass.
  • Generic parameters preserved on resolved types. Catch clause variables, pass-by-reference parameters, closure parameters, and constructor calls now thread the original type hint (including generic parameters) through the resolution pipeline instead of discarding it.
  • Type-guard narrowing no longer drops class info on unions. Narrowing a union like Foobar|string|int with is_string()/is_int() in elseif chains now correctly preserves class info for the remaining class member.
  • False-positive undefined-variable diagnostic on static property access. self::$prop, static::$prop, and ClassName::$prop no longer trigger an undefined variable warning. Dynamic forms (self::$$prop, self::${expr}) still correctly flag undefined variables used in the expression. Contributed by @lucasacoutinho in https://github.com/PHPantom-dev/phpantom_lsp/pull/75.
  • Case-insensitive self, static, and parent resolution. SELF::method(), Static::create(), PARENT::foo(), and other non-lowercase spellings now resolve correctly. Previously only the exact lowercase forms were recognized.
  • Property type resolution in call arguments. When a method argument is $this->prop and the property has a generic, nullable, or union type, the full type structure is now preserved. Previously only the base class name was extracted, discarding generics and union components.
  • Update docblock enrichment comparison. The "Update docblock" code action now uses structural type comparison instead of string equality when deciding whether a @param type needs enrichment. Types that are semantically equivalent but formatted differently (e.g. \App\User vs App\User) no longer trigger spurious updates. Body-based @return enrichment now correctly detects when an existing @return tag already has type structure, instead of always proposing a replacement.
  • @phpstan-assert and @psalm-assert tags with generic types. Assertions like @phpstan-assert Collection<int, User> $param now parse the full generic type instead of truncating at the first space inside angle brackets.
  • parent::method() resolution in inline arguments. Passing parent::method() as an argument to a function now resolves the return type correctly, matching the existing handling for self:: and static::.
  • Laravel Eloquent Builder and Collection type resolution. Generic and nullable types on Eloquent models (e.g. Collection<int, User>, ?User) now resolve correctly when used for Builder scope injection, custom collection swapping, and relationship chain inference. Previously these types were stringified with their generic parameters or nullable prefix, causing lookups to fail silently.
  • Docblock generation no longer panics on lines with multibyte characters. Files containing non-ASCII characters (e.g. accented letters) could cause the /** docblock trigger to crash or produce misaligned edits due to a mismatch between UTF-16 column offsets and byte offsets.
  • Conditional return types showing mixed in hover. When a method with a conditional return type (e.g. @phpstan-return ($type is class-string<T> ? T : mixed)) resolved to a concrete class, hover still displayed the method's declared return type (mixed) instead of the resolved class. Affects methods like Symfony's SerializerInterface::deserialize().
  • Method-level @throws types now resolve short names to FQN. Exception types in @throws tags on class methods are now fully qualified using the file's use imports, matching the behaviour already in place for standalone functions. Cross-file throws propagation and the "Update docblock" code action produce correct results when the exception class is imported via a use statement.
  • Missing diagnostics and import actions in files without a namespace. When a namespaced class (e.g. Carbon\Carbon) had already been parsed, using its short name (Carbon) in a file without a namespace declaration incorrectly resolved against the namespaced class. This suppressed both the "class not found" diagnostic and the "Import" code action. Bare-name lookups now only match classes that are themselves in the global namespace.
  • Find-references false positives for global classes. Searching for references to a global-scope class (e.g. Helper with no namespace) could include references to unrelated namespaced classes with the same short name (e.g. App\Helper). Short-name fallback matching now only applies when the resolved name is unqualified.
  • Fluent chains only flag the first broken link. In a chain where the first method does not exist, only that method is flagged instead of every subsequent call receiving its own warning.
  • Null narrowing from !== null checks. Null-initialized variables guarded by $var !== null, !is_null(), or bare truthy checks now have null narrowed away inside the then-body and in subsequent && operands. Works in chained conditions, ternary expressions, and return statements.
  • Variables assigned inside if/while conditions now resolve in the body. if ($admin = AdminUser::first()) and while ($row = nextRow()) register the assignment so the variable has a type inside the branch or loop body.
  • Loop-body assignments not visible inside the same loop iteration. When a variable is initialized as null and reassigned later in a loop body, the assigned type is now visible at every point inside the loop. Combined with null narrowing, variables correctly resolve to the assigned class type.
  • @var docblock annotations no longer leak across class and method boundaries. A @var annotation for a same-named variable in a different class no longer bleeds into the current scope.
  • Inline @var cast no longer overrides the variable type on the RHS of the same assignment. /** @var array<string, mixed> */ $data = $data->toArray() no longer resolves the RHS $data using the cast type.
  • Foreach over union types containing arrays now resolves the element type. A parameter typed User|array<User> iterated with foreach now correctly yields User as the loop variable type. Previously the element type extraction did not look inside union members, producing no completions.
  • @param docblock overrides ignored when the native type hint resolves. When a parameter has both a native type hint and a more specific @param override, the docblock type now takes effect. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/55.
  • Variable reassignment inside try/catch/finally blocks now tracked. Subsequent accesses within the same block resolve against the reassigned type instead of the original.
  • Self-referential variable reassignments in nested loops no longer produce false "type could not be resolved" diagnostics. Recursive resolution that hits the depth limit no longer poisons the cache for later lookups.
  • instanceof narrowing with unresolvable target class. When the target class cannot be loaded, the variable's type is treated as unknown instead of keeping the un-narrowed type, eliminating false positives for members on the narrowed subclass.
  • stdClass and object types no longer produce false-positive diagnostics. Variables typed as object or stdClass now permit arbitrary property access. is_object() correctly narrows mixed to object and compound && conditions propagate the narrowing.
  • Docblock type refinement no longer matches class names containing type keywords. A class named PointOfInterest would incorrectly be treated as an int refinement because the refinement check used substring matching. Refinement compatibility now uses structural type predicates.
  • class-string<T> static method dispatch. Calling static methods on a class-string<Foo> variable now resolves return types correctly, including static substitution to the bound class.
  • self/static/$this in cross-file method return types now resolve correctly. When a method on a cross-file class returns a type referencing self (e.g. @return HasMany<self, $this>), the owning class was looked up by short name through the consuming file's import table, which failed when the consuming file did not import that class. The owning class is now looked up by its fully-qualified name.
  • in_array guard clause no longer wipes out variable type. When the haystack's element type matches the variable's type, the narrowing system no longer excludes the type entirely.
  • Method chains through __call no longer lose the return type. When __call returns $this, static, or self, the chain type is preserved through dynamic method calls.
  • Scope methods on Eloquent Builder no longer produce false-positive diagnostics. Bare Builder return types on scope methods are automatically wrapped as Builder<ConcreteModel> to preserve the chain.
  • Scope methods missing from completion on relationship results. Scope methods from related models now appear in completions, not just hover.
  • Closure and variable hover now preserves generic arguments. Closure parameters inferred from callable signatures, variables assigned from chained methods returning static/$this/self, and hovering on the $ sign of a variable at its assignment site all now show the correct generic type.
  • Callable parameter inference preserves generic arguments from the receiver. A closure typed as fn(Builder $q) inside a Builder<Product> chain now infers $q as Builder<Product>, so model-specific scope methods resolve correctly.
  • @see tags in floating docblocks now support go-to-definition. Docblock comments not directly attached to a class, function, or statement (e.g. inline /** @see SupervisorOptions::$balanceCooldown */ inside array literals or after expressions) are now parsed for symbol references. Previously these were silently ignored, particularly in files without a namespace.
  • Nullable static return types on inherited methods. Methods returning ?static or static|null now correctly resolve to the calling subclass across files.
  • Template binding with nested generics. Parameter types like Wrapper<Collection<T>, V> no longer break during template binding.
  • Single generic argument on collections bound to the wrong template parameter. Collection<User> now binds to the value parameter instead of the key parameter when key-like template parameters precede value parameters.
  • Nullable return types losing |null after template substitution. @return TValue|null now preserves |null through substitution, so calls like ::first() correctly show the nullable type.
  • @mixin referencing a template parameter now resolves. A class with @template T and @mixin T now pulls in methods from the concrete type passed via generic arguments.
  • @property and @method tags losing nullable types. Tags like @property int|null $foo no longer have |null stripped.
  • Callable types inside unions displayed ambiguously. (Closure(int): string)|Foo is now parenthesized correctly in hover and completions.
  • Hover and go-to-definition on attributes. Attributes on properties, class constants, parameters, and enum cases are now navigable.
  • Function-level @template with generic wrapper parameters. Template substitution at call sites now correctly handles array, iterable, and list as wrapper names.
  • Closure parameter inference from function-level @template bindings. Functions like array_any and array_all now infer concrete types for untyped closure arguments from the array parameter's element type.
  • Property chain arguments in template substitution. Expressions like $this->items passed to templated functions now resolve their type for template binding.
  • Variadic parameter element type lost in foreach. Iterating over a variadic parameter now resolves the loop variable to the element type.
  • Anonymous class variables now resolve their type. $model = new class extends Foo { ... } followed by $model->method() now resolves through the anonymous class's inherited members.
  • Namespaced functions imported via use function no longer flagged as unknown. Functions defined in one file and imported via use function in another now resolve correctly.
  • parent::method() return type resolution in variable analysis. Calling parent::method() and assigning the result now correctly resolves the parent method's return type.
  • Closure parameter inference inside switch cases and if conditions. Closure parameters that should be inferred from the enclosing callable context now resolve correctly when the closure appears inside a switch case or if-condition.
  • Generic arguments propagated through transitive @extends chains. When a class extends a parent that itself extends a generic grandparent, generic arguments now flow through the full chain.
  • Stack overflow when a foreach value variable shadows the iterator receiver. Patterns like foreach ($category->getBranch() as $category) no longer cause infinite recursion.
  • PHPStan * wildcard in generic type arguments. Type strings like Relation<TRelatedModel, *, *> now parse correctly.
  • Types with covariant or contravariant variance annotations in generic args now parse correctly. Annotations like BelongsTo<Category, covariant $this> no longer cause the entire type to become unresolvable.
  • Diagnostics now work for vendor files open in the editor. Projects using --prefer-source or monorepo setups no longer have diagnostics suppressed in vendor files.
  • PHPStan diagnostics no longer hidden by unrelated native diagnostics on the same line. Deduplication now only suppresses a full-line diagnostic when the precise diagnostic on the same line reports a related issue.
  • Nullable boolean properties now use is prefix for getters. Properties typed ?bool or ?boolean now generate isFoo() instead of getFoo() when using the "Generate getter" code action.
  • Aliased namespace imports used in attributes no longer flagged as unused. use Symfony\Component\Validator\Constraints as Assert; with #[Assert\Uuid(...)] no longer produces a false "Unused import" diagnostic.
  • DB::select() return type. DB::select() and related methods now return array<int, stdClass> instead of bare array, and DB::selectOne() returns ?stdClass.
  • Redis Connection method resolution. Redis commands on Illuminate\Redis\Connections\Connection now resolve through the phpredis stubs.
  • Array shape tracking from keyed assignments inside conditional branches. Shape types built incrementally with variable keys inside loops with if/else branching are now preserved through foreach iteration.
  • Deprecated class in implements renders with strikethrough. Deprecated classes referenced in implements clauses are correctly tagged.
  • Interleaved array access and property chains no longer produce false positives. Expressions like $results[$i]->activities[$id]->extras where array subscript and property access alternate were incorrectly parsed, causing the intermediate property chain to be dropped. This led to "Property not found on class" false positives when the element type was resolved but the subsequent property lookup was skipped.
  • FQN \assert() now narrows types. Writing \assert($var instanceof Foo) with a leading backslash was not recognized as an instanceof narrowing, causing false-positive "property not found" diagnostics after the assertion.
  • Generic template substitution producing invalid types. When a template parameter was the base of a generic type (e.g. T<int> where T maps to Collection<string>), the substitution produced malformed types like Collection<string><int>. The replacement's base name is now used correctly, yielding Collection<int>.

0.6.0 - 2026-03-26

Added

  • Semantic Tokens. Type-aware syntax highlighting that goes beyond what a TextMate grammar can achieve. Classes, interfaces, enums, traits, methods, properties, parameters, variables, functions, constants, and template parameters all get distinct token types. Modifiers convey declaration sites, static access, readonly, deprecated, and abstract status.
  • PHPStan diagnostics. PHPStan errors appear inline as you edit. Auto-detects vendor/bin/phpstan or $PATH. Runs in the background without blocking native diagnostics. Configurable via [phpstan] in .phpantom.toml (command, memory-limit, timeout). "Ignore PHPStan error" and "Remove unnecessary @phpstan-ignore" code actions manage inline ignore comments.
  • Formatting. Built-in PHP formatting (PER-CS 2.0 style). Formatting works out of the box without any external tools. Projects that depend on php-cs-fixer or PHP_CodeSniffer in their composer.json require-dev automatically use those tools instead (both can run in sequence). Per-tool command overrides and disable switches in [formatting] in .phpantom.toml.
  • Inlay hints. Parameter name and by-reference indicators appear at call sites. Hints are suppressed when the argument already makes the parameter obvious: variable names matching the parameter, property accesses with a matching trailing identifier, string literals whose content matches, well-known single-parameter functions like count and strlen, and spread arguments. Named arguments never receive a redundant hint.
  • PHPDoc block generation. Typing /** above any declaration generates a docblock skeleton. Tags are only emitted when the native type hint needs enrichment. Properties and constants always get @var. Class-likes with templated parents or interfaces get @extends/@implements tags. Uncaught exceptions get @throws with auto-import. Works both via completion and on-type formatting.
  • Syntax error diagnostic. Parse errors from the Mago parser now appear as Error-severity diagnostics instantly as you type.
  • Implementation error diagnostic. Concrete classes that fail to implement all required methods from their interfaces or abstract parents are now flagged with an Error-severity diagnostic on the class name. The existing "Implement missing methods" quick-fix appears inline alongside the error.
  • Argument count diagnostic. Flags function and method calls that pass too few arguments. The "too many arguments" check is off by default (PHP silently ignores extra arguments) and can be enabled with extra-arguments = true in the [diagnostics] section of .phpantom.toml.
  • Completion item documentation. Selecting a completion item in the popup now shows rich documentation including the full typed signature, description, deprecation notice, and parameter details. Previously only the class name was shown.
  • Method commit characters. Typing ( while a method completion is highlighted auto-accepts it and begins the argument list.
  • Document Symbols. The outline sidebar and breadcrumbs now show classes, interfaces, traits, enums, methods, properties, constants, and standalone functions with correct nesting, icons, visibility detail, and deprecation tags.
  • Workspace Symbols. "Go to Symbol in Workspace" (Ctrl+T / Cmd+T) searches across all indexed files including vendor classes. Results include namespace context and deprecation markers, sorted by relevance.
  • Type Hierarchy. "Show Type Hierarchy" on any class, interface, trait, or enum reveals its supertypes and subtypes with full up-and-down navigation through the inheritance tree, including cross-file resolution and transitive relationships.
  • Code Lens. Clickable annotations above methods that override a parent class method or implement an interface method. Clicking navigates to the prototype declaration.
  • Update docblock. Code action on a function or method whose existing docblock is out of sync with its signature. Adds missing @param tags, removes stale ones, reorders to match the signature, fixes contradicted types, and removes redundant @return void. Refinement types and unrelated tags are preserved. Only triggers on the signature or the preceding docblock, not inside the function body.
  • Change visibility. Code action on any method, property, constant, or promoted constructor parameter offers to change its visibility (public, protected, private). Only triggers on the declaration signature, not inside the body.
  • @throws code actions. Quick-fixes for adding missing and removing unnecessary @throws tags, triggered by PHPStan diagnostics. Adding inserts the tag and a use import when needed. Removing cleans up orphaned blank lines and deletes the entire docblock when it would be empty. The diagnostic disappears on the next keystroke without waiting for the next PHPStan run.
  • File rename on class rename. Renaming a class whose file follows PSR-4 naming now also renames the file to match. The file is only renamed when it contains a single class-like declaration and the editor supports file rename operations.
  • Folding Ranges. AST-aware code folding for class bodies, method/function bodies, closures, arrays, argument/parameter lists, control flow blocks, doc comments, and consecutive single-line comment groups.
  • Selection Ranges. Smart select / expand selection returns AST-aware nested ranges from innermost to outermost.
  • Document Links. require/include paths are now Ctrl+Clickable. Path resolution supports string literals, __DIR__ concatenation, dirname(__DIR__), dirname(__FILE__), and nested dirname with levels.
  • Analyze command. phpantom_lsp analyze scans a Composer project and reports PHPantom's own diagnostics in a PHPStan-like table format. Useful for measuring type coverage across an entire codebase without opening files one by one. Accepts an optional path argument to limit the scan to a single file or directory. Output includes diagnostic identifiers and supports --severity filtering and --no-colour for CI.
  • Null-coalesce (??) type refinement. When the left-hand side of ?? is provably non-nullable (e.g. new Foo(), clone $x, a literal), the right-hand side is recognized as dead code and the result resolves to the LHS type only. When the LHS is nullable (e.g. a ?Foo return type), null is stripped from the LHS and the result is the union of the non-null LHS with the RHS.
  • @mixin generic substitution. When a class declares @mixin Foo<T>, the generic arguments are now preserved and substituted into the mixin's members, including through multi-level inheritance chains.
  • PHPDoc @var completion. Inline @var above variable assignments sorts first and pre-fills the inferred type when available. Template parameters from @template enrich @param, @return, and @var type hints.
  • @see and @link improvements. @see references in docblocks now work with go-to-definition (class, member, and function forms). Hover popups show all @link and @see URLs as clickable links. Deprecation diagnostics include @see targets when the @deprecated docblock references them.
  • Progress indicators. Go to Implementation and Find References now show a progress indicator in the editor while scanning.
  • Phar archive class resolution. Classes inside .phar archives (e.g. PHPStan's phpstan.phar) are now discovered and indexed automatically. No PHP runtime needed. Only uncompressed phars are supported (the format used by PHPStan and most other phar-distributed tools).
  • PSR-0 autoload support. Packages that use the legacy PSR-0 autoloading standard are now discovered automatically.
  • Global config. Settings from a global .phpantom.toml in the user's config directory (typically ~/.config/phpantom_lsp/.phpantom.toml) are now loaded as defaults. Project-level configs take precedence. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/39.
  • Config schema. A JSON schema for .phpantom.toml is now bundled, enabling autocompletion and validation in editors that support TOML schemas. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/38.

Changed

  • Pull diagnostics. Diagnostics are now delivered via the LSP 3.17 pull model when the editor supports it. The editor requests diagnostics only for visible files, and cross-file invalidation no longer recomputes every open tab. Clients without pull support fall back to the previous push model automatically.
  • Hover type accuracy. Hover now resolves variable types through the same pipeline as completion, so all narrowing features (instanceof, assert, custom type guards, in_array) apply. When the cursor is inside a specific if/else branch, hover shows only the type visible in that branch. Complex expressions like null-coalesce chains, array shapes, empty arrays, and unresolved symbols all display correctly.
  • Version-aware stub types. Built-in function signatures that changed across PHP versions (e.g. int|false in 7.x becoming int in 8.0) now show the correct type for your project's PHP version. This eliminates false-positive diagnostics and incorrect completions from stale type annotations.
  • Completion labels. Method and function completion items now show only parameter names in the label (e.g. setName($name)) with the return type displayed inline (e.g. : User). Properties and constants show just the type hint. The previous Class: ClassName detail line has been removed; class context is available in the documentation panel when the item is highlighted.
  • Completion sort order. Member completion items are now sorted by kind (constants, then properties, then methods) before alphabetical order within each group. Union-type completions apply the same kind-based ordering within both the intersection and branch-only tiers.
  • Class name completion ranking. Completions now rank by match quality first (exact match, then starts-with, then substring), so typing Order puts Order above OrderLine above CheckOrderFlowJob regardless of where the class comes from. Within each match quality group, use-imported and same-namespace classes appear first, followed by everything else sorted by namespace affinity (classes from heavily-imported namespaces rank higher).
  • Use-import completion. Same-namespace classes no longer appear in use statement completions (PHP auto-resolves them without an import). Classes that are already imported are filtered out. Namespace affinity still ranks the remaining candidates.
  • Deprecation tags. Completion items use the modern tags: [DEPRECATED] field instead of the legacy deprecated boolean. Both convey the same strikethrough rendering in editors.
  • Import class code action ordering. The "Import Class" code action now sorts candidates by namespace affinity (derived from existing imports) instead of alphabetically, so the most likely namespace appears first.
  • Cross-file resolution. Completion, hover, and go-to-definition no longer fail when one reference uses a leading backslash and another does not.
  • Embedded stubs track upstream master. The bundled phpstorm-stubs are now pulled from the master branch instead of the latest GitHub release, matching what PHPStan does. This brings in upstream fixes and new PHP version annotations weeks or months before a formal release.

Fixed

  • CLI analyze performance. Single-file analysis is up to 5.8× faster. Full-project analysis of ~2 500 files is up to 10× faster.
  • Diagnostic performance on large files. Unknown-member diagnostics on files with many member accesses are up to 7× faster.
  • Position encoding. All LSP position conversions now correctly count UTF-16 code units, matching the LSP specification. Files containing emoji or supplementary Unicode characters no longer produce incorrect positions.
  • Rename and find references for parameters. Renaming a parameter in a function, method, or closure now correctly updates all usages in the body and the @param tag in the docblock. Previously, parameters were scoped incorrectly because they sit physically before the opening { of the body, causing rename and find references to miss body usages when triggered from the parameter (and vice versa). Document highlight is also fixed.
  • Rename updates imports. Renaming a class now updates use statement FQNs, preserves explicit aliases, and introduces an alias when the new name collides with an existing import.
  • False-positive diagnostics for $this inside traits. Accessing host-class members via $this->, self::, static::, or parent:: inside a trait method no longer produces "not found" warnings, including chain expressions and accesses inside closures or arrow functions nested within trait methods.
  • False-positive diagnostics for same-named variables in different methods. Diagnostic resolution is now scoped to the enclosing function/method/closure body, so two methods using a variable like $order resolve it independently.
  • False positive on namespaced constants. Standalone namespaced constant references (e.g. \PHPStan\PHP_VERSION_ID) no longer produce a spurious "Class not found" diagnostic. Previously the symbol map classified them as class references instead of constant references.
  • Diagnostic deduplication. Multiple diagnostics on the same span or line are no longer collapsed into one. If PHPStan reports five issues on a line, all five are shown. When PHPantom and PHPStan both flag the same issue, the more precise native diagnostic wins.
  • Diagnostics. Enums that implement interfaces are now checked for missing methods. Scalar member access errors detect method-return chains where an intermediate call returns a scalar type. By-reference @param annotations no longer produce a false "unknown class" diagnostic.
  • Removed PHP symbols in stubs. Functions, methods, and classes annotated with @removed X.Y in phpstorm-stubs are now filtered out when the target PHP version is at or above the removal version. Previously symbols like mysql_tablename (removed in PHP 7.0) and each (removed in PHP 8.0) appeared in completions and resolved without warnings.
  • Hover on union member access. Hovering over a method, property, or constant on a union type (e.g. $ambiguous->turnOff() where $ambiguous is Lamp|Faucet) now shows hover information from all branches that declare the member, separated by a horizontal rule. Previously only the first matching branch was shown. When both branches inherit the member from the same declaring class, the hover is deduplicated to a single entry.
  • Hover on inherited members. Hovering over an inherited method, property, or constant now shows the declaring class in the code block (e.g. class Model { public static function find(...) }) instead of the class it was accessed on. Previously User::find() would incorrectly show class User even though find() is declared on Model.
  • Constant type inference. Variables assigned from global constants ($a = MY_CONST) or class constants without type hints ($b = Config::TIMEOUT) now resolve to the type implied by the constant's initializer value. Integer, float, string, bool, null, and array literals are all recognised. Typed class constants (public const string NAME = '...') continue to use their declared type hint.
  • Variable type after reassignment. When a method parameter is reassigned mid-body (e.g. $file = $result->getFile()), subsequent member accesses now resolve against the new type instead of the original parameter type.
  • Variable assignments inside foreach loops. Variables conditionally reassigned inside a foreach body are now visible after the loop.
  • Variable-to-variable type propagation. Assignments like $found = $pen now resolve $found to the type of $pen. This also eliminates false-positive diagnostics when the initial assignment was $found = null and a later reassignment provided the real type.
  • Variable type inside self-referencing assignment RHS. In $request = new Foo(arg: $request->uuid), the $request reference inside the constructor arguments now correctly resolves to the original type instead of the type being assigned.
  • Variable resolution inside anonymous classes. Variables inside anonymous class methods (e.g. closure parameters in return new class extends Migration { ... }) now resolve correctly. Previously, anonymous class bodies were invisible to the variable resolution pipeline because they appear as expressions inside statements rather than top-level class declarations.
  • Closure and arrow function variable scope. Variable name completion now correctly respects PHP scoping rules for anonymous functions and arrow functions. Parameters and use-captured variables are visible inside closures. Arrow function parameters are visible inside the arrow body while the enclosing scope's variables remain accessible.
  • Function return type resolution across files. Standalone functions that declare return types using short names from their own use imports now resolve correctly in consuming files. Function parameter types and @throws types are also resolved.
  • Native type override compatibility. A docblock type only overrides a native type hint when it is a compatible refinement (e.g. class-string<Foo> can refine string, but array<int> no longer incorrectly overrides string).
  • PHPStan pseudo-type recognition. Types like non-positive-int, non-negative-int, non-zero-int, lowercase-string, truthy-string, callable-object, and many other PHPStan pseudo-types are now recognized across the entire pipeline.
  • Nullable and generic types in class lookup. Variables typed as ?ClassName or Collection<Item> now resolve correctly across all code paths.
  • Generic substitution through transitive interface chains. When a class implements an interface that itself extends another generic interface, template parameters are now substituted at each level instead of propagating raw template parameter names.
  • Generic shape substitution. Template parameters inside array shapes (array{data: T}) and object shapes (object{name: T}) are now correctly substituted when inherited through @extends.
  • Type narrowing with same-named classes from different namespaces. instanceof narrowing now correctly distinguishes classes that share a short name but live in different namespaces (e.g. Contracts\Provider vs Concrete\Provider).
  • Guard clause narrowing across instanceof branches. After if ($x instanceof Y) { return; }, subsequent instanceof checks on the same variable no longer incorrectly resolve to Y.
  • instanceof self/static/parent narrowing. Type narrowing with instanceof self, instanceof static, and instanceof parent now works correctly in all contexts (assert, if-blocks, guard clauses, compound conditions).
  • Type narrowing inside return statements. instanceof checks in && chains and ternary conditions now narrow the variable type when the expression is the operand of a return statement.
  • Inline array access on method returns. Expressions like $c->items()[0]->getLabel() now resolve the element type correctly for both completion and diagnostics.
  • Array shape bracket access. Variables assigned from string-key bracket access on array shapes ($name = $data['name']) now resolve to the correct value type. Chained access ($first = $result['items'][0]) walks through shape keys and generic element types in sequence.
  • Ternary and null-coalesce member access. Accessing a member on a ternary or null-coalesce expression (e.g. ($a ?: $b)->property, ($x ?? $y)->method()) now resolves correctly for hover, go-to-definition, and diagnostics.
  • Null-safe method chain resolution. Null-safe method calls ($obj?->method()) now resolve the return type correctly for variable type inference, including cross-file chains.
  • Clone expressions. (clone $var)-> now resolves to the same type as $var, providing correct completion, hover, and diagnostics.
  • self::/static::/parent:: in member access chains. Expressions like self::Active->value inside an enum method now resolve correctly. Previously, self, static, and parent were only recognized as bare subjects, not when followed by ::MemberName in a chain.
  • Inherited methods missing through deep stub chains. Methods are now found on classes that inherit through multi-level chains where intermediate classes live in stubs.
  • Interface constants through multi-extends chains. Constants defined on parent interfaces are now found when an interface extends multiple other interfaces.
  • Double parentheses when completing calls. Completing a function, constructor, or static method name when parentheses already follow the cursor (e.g. array_m|(), new Gadge|(), throw new Excepti|()) no longer inserts a second pair of parentheses. Previously only -> and :: method calls were handled.
  • Namespace alias completion. Typing a class name through a namespace alias (e.g. OA\Re with use OpenApi\Attributes as OA) now correctly suggests classes under the aliased namespace.
  • Catch clause completion. Throwable interfaces and abstract exception classes now appear in catch clause completions.
  • Type-hint and PHPDoc completion. Traits are now excluded from completions in parameter types, return types, property types, and PHPDoc type tags. @throws continues to use Throwable-filtered completion.
  • Trait alias go-to-definition. Clicking a trait alias (e.g. $this->__foo() from use Foo { foo as __foo; }) now jumps to the trait method instead of the class's own same-named method.
  • Self-referential array key assignments no longer crash. Patterns like $numbers['price'] = $numbers['price']->add(...) no longer cause a stack overflow during hover or completion.
  • Eloquent morphedByMany relationships. The inverse side of polymorphic many-to-many relationships is now recognised. Virtual properties and _count properties are synthesized for models using this relationship type.
  • Virtual property merging. Native type hints are now considered when determining virtual property specificity, preventing properties with native PHP type declarations from being incorrectly overridden by less specific virtual properties.

0.5.0 - 2026-03-12

Added

  • Diagnostics. Unknown classes, unknown members, and unknown functions are flagged with appropriate severity. An opt-in unresolved member access diagnostic is available via .phpantom.toml.
  • Find References. Locate every usage of a symbol across the project. Supports classes, methods, properties, constants, functions, and variables. Variable references are scoped to the enclosing function or closure. Member references are scoped to the class hierarchy, so unrelated classes sharing a method name are excluded.
  • Rename. Rename variables, classes, methods, properties, functions, and constants across the workspace. Variable renames are scoped to their enclosing function or closure.
  • Deprecation support. @deprecated tags and #[Deprecated] attributes surface in hover, completion strikethrough, and diagnostics. A quick-fix code action rewrites deprecated calls when a replacement template is available.
  • Document highlighting. Placing the cursor on a symbol highlights all occurrences in the current file. Variables are scoped to their enclosing function or closure with write vs. read distinction.
  • Implement missing methods. Code action that generates method stubs when a class is missing required interface or abstract method implementations.
  • Project configuration. .phpantom.toml for per-project settings: PHP version override, diagnostic toggles, and indexing strategy. Run phpantom --init to generate a default config.
  • Reverse go-to-implementation. Go-to-implementation on a concrete method jumps to the interface or abstract class that declares the prototype, and vice versa.
  • Go to Type Definition. Jump from a variable, property, method call, or function call to the class declaration of its resolved type. Union types produce multiple locations.
  • Self-generated classmap. PHPantom works without composer dump-autoload -o. Missing or incomplete classmaps are supplemented by scanning autoload directories. Non-Composer projects are supported by scanning all PHP files.
  • Monorepo support. Discovers subdirectories that are independent Composer projects and processes each through the full pipeline.
  • @implements generic resolution. @implements Interface<ConcreteType> substitutes template parameters on the interface's methods and properties. Foreach iteration on generic iterable interfaces resolves value and key types.
  • Interface template inheritance. Implementing classes inherit @template parameters, bindings, conditional return types, and type assertions from their interfaces.
  • Function-level @template with generic return types. Functions that use @template parameters inside generic return types now resolve concrete types from call-site arguments.
  • Generic @phpstan-assert with class-string<T>. Assertion methods that accept a class-string<T> parameter resolve the narrowed type from the call-site argument.
  • Property-level narrowing. if ($this->prop instanceof Foo) narrows $this->prop in then/else bodies and after guard clauses.
  • Inline && short-circuit narrowing. The right-hand side of && now sees the narrowed type from the left-hand side.
  • Compound negated guard clause narrowing. if (!$x instanceof A && !$x instanceof B) { return; } narrows $x to A|B in the surviving code.
  • Closure variable scope isolation. Variables outside a closure are no longer offered as completions unless captured via use().
  • Pipe operator (PHP 8.5). $input |> trim(...) |> createDate(...) resolves through the chain.
  • AST-based array type inference. Array shape keys, element access, spread elements, and push-style assignments all resolve through an AST walker.
  • new $classStringVar and $classStringVar::method(). Class-string variables resolve for new and static member access.
  • Invoked closure and arrow function return types. (fn(): Foo => ...)() and (function(): Bar { ... })() resolve to their return type.
  • Docblock navigation. Go-to-definition and hover work on class names inside callable types, array/object shape value types, and object shape properties.
  • GTD from parameter and property variables. Clicking a parameter or property at its definition site jumps to the type hint class.
  • PHP version-aware stubs. Detects the target PHP version from composer.json and filters built-in stub signatures accordingly.
  • @param-closure-this. $this inside a closure resolves to the type declared by @param-closure-this on the receiving parameter.
  • Non-Composer function and constant discovery. Cross-file function completion, go-to-definition, and constant resolution for projects without composer.json.
  • Indexing progress indicator. The editor shows a progress bar during workspace initialization, including per-subproject progress in monorepos.
  • Pass-by-reference parameter type inference. After calling a function with a typed &$var parameter, the variable acquires that type.
  • iterator_to_array() element type. Resolves the element type from the iterator's generic annotation.
  • Enum case properties. $case->name and $case->value resolve on enum case variables.
  • Inline @var on promoted constructor properties. Overrides the native type hint, matching existing @param support.
  • --version and --help CLI flags. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/7.

Changed

  • Resolution engine rewritten on AST. Variable type inference, call return types, and go-to-definition all run through the AST walker for better accuracy.
  • Hover redesigned. Short names with namespace line, actual default values, @link URLs, precise token highlighting, constructor signatures on new, @template details, enum case listing, trait member listing, origin indicators, and deprecated explanations.
  • Signature help enriched. Compact parameter list with native types, per-parameter @param descriptions, default values, and attribute parenthesis support.
  • Faster resolution and lower memory usage.
  • Parallel workspace indexing. File parsing, PSR-4 scanning, and vendor scanning run across all CPU cores. .gitignore rules are respected.
  • Two-phase diagnostic publishing. Cheap diagnostics (unused imports, deprecation) publish immediately; expensive diagnostics (unknown classes/members/functions) arrive in a second pass.
  • Merged classmap + self-scan pipeline. Composer classmaps and self-scanning work together instead of being mutually exclusive. Stale classmaps are supplemented automatically.
  • Automatic stub fetching. The build script downloads phpstorm-stubs automatically when missing. Composer is no longer needed to build PHPantom. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/16.
  • Feature comparison table corrected. Phactor capabilities updated in the README. Contributed by @dantleech in https://github.com/PHPantom-dev/phpantom_lsp/pull/10.

Fixed

  • Cross-file inheritance from global-scope classes imported via use.
  • Inherited @method and @property tags across files.
  • Diagnostics refresh across open files when a class signature changes.
  • Variable types resolve through ternary, elvis, null-coalesce, and match assignments.
  • instanceof narrowing no longer widens specific types.
  • Elseif chain narrowing and sequential assert narrowing.
  • @phpstan-type aliases in foreach, list(), and key types.
  • False-positive unknown-class warnings on PHPStan type syntax.
  • Go-to-implementation no longer produces false positives across namespaces.
  • __invoke() return type resolution. Works with chaining, foreach, and parenthesized invocations.
  • Enum from() and tryFrom() chaining.
  • static/self/$this in method return types used as iterable expressions.
  • Mixed -> then :: accessor chains.
  • Inline (new Foo)->method() chaining.
  • ?-> null-safe chain resolution.
  • Array function resolution for array_pop, array_filter, array_values, end, array_map.
  • Inline @var annotations no longer leak across scopes.
  • Literal string conditional return types.
  • Class constant and enum case assignment resolution.
  • Go-to-definition on trait as alias and insteadof declarations.
  • Inline array-element function calls resolve correctly in diagnostics. end($obj->items)->method() no longer produces a false diagnostic.
  • Double-negated instanceof narrowing.
  • Self-referential array key assignments no longer crash.

0.4.0 - 2026-03-01

Added

  • Signature help. Parameter hints in function/method calls with active parameter highlighting.
  • Hover. Type, signature, and docblock in a Markdown popup for all symbol kinds.
  • Closure and callable inference. Untyped closure parameters inferred from the callable signature. First-class callable syntax resolves return types.
  • Laravel Eloquent. Relationships, scopes, Builder forwarding, factories, custom collections, casts, accessors, mutators, $attributes, and $visible.
  • Type narrowing. in_array() with strict mode, early return guards, instanceof in ternaries and with interfaces.
  • Anonymous class support. $this-> resolves inside anonymous classes with full inheritance support.
  • Context-aware completions. extends, implements, use inside class body, union member sorting, namespace segments, string literal suppression.
  • Additional resolution. Multi-line chains, nested array keys, generator yield types, conditional return types with template substitution, switch/unset variable tracking.
  • Transitive interface go-to-implementation.

Fixed

  • Visibility filtering, scope isolation, static call chains, static return type, trait resolution, mixin fluent chains, go-to-definition accuracy, import handling, UTF-8 boundaries, and parenthesized RHS expressions.

0.3.0 - 2026-02-21

Added

  • Go-to-implementation. Interface/abstract class to all concrete implementations.
  • Method-level @template. Infers T from the call-site argument.
  • @phpstan-type / @psalm-type aliases and @phpstan-import-type.
  • Array function type preservation. array_filter, array_map, array_pop, current, etc.
  • Early return narrowing. Guard clauses narrow types for subsequent code.
  • Callable variable invocation. $fn()-> resolves return types.
  • Additional resolution. Spread operators, trait insteadof/as, chained assignments, destructuring, foreach on function returns, type hint completion, try-catch suggestions.

Fixed

  • PHPDoc type parsing and internal stability fixes.

0.2.0 - 2026-02-18

Added

  • Generics. Class-level @template with @extends substitution. Method-level class-string<T>. Generic trait substitution.
  • Array shapes and object shapes. Key completion from literals, incremental assignments, destructuring, element access.
  • Foreach type resolution. Generic iterables, array shapes, Collection<User>, Generator<int, Item>, IteratorAggregate.
  • Expression type inference. Ternary, null-coalescing, and match expressions.
  • Additional completions. Named arguments, variable name suggestions, standalone functions, define() constants, PHPDoc tags, deprecated members, promoted property types, property chaining, require_once discovery, go-to type definition.

Fixed

  • @mixin context for return types, global class imports, namespace resolution, and aliased class go-to-definition.

0.1.0 - 2026-02-16

Initial release.

Added

  • Completion. Methods, properties, and constants via ->, ?->, and :: with visibility filtering.
  • Type resolution. Inheritance merging, self/static/parent, union types, nullsafe chains.
  • PHPDoc support. @return, @property, @method, @mixin, conditional return types, inline @var.
  • Type narrowing. instanceof, is_a(), @phpstan-assert.
  • Enum support. Case completion and UnitEnum/BackedEnum interface members.
  • Go-to-definition. Classes, methods, properties, constants, functions, new expressions, variables.
  • Class name completion with auto-import.
  • PSR-4 lazy loading and Composer classmap support.
  • Embedded phpstorm-stubs.
  • Zed editor extension.