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 = trueunder[diagnostics]in.phpantom.tomland, 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-signaturedocblock is the template's contract;@propsand@awarefill in what it leaves out; a component's own class, and Livewire's$this, supply their members;View::share()andView::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'sfirst(),renderWhen(),renderUnless(), andrenderEach(), a mailable'snew Content(view: …)and$this->view(), and Blade's own@includefamily,@extends, and@eachall 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, itsmount()signature, or its@propsentries. 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$attributesare left alone. Components registered by a service provider, addressed by their directory alone, or reached through an anonymous prefix are all found, and$componentis bound inside the tag body so completion and hover work on it. - A Blade
@sectionknows where its other half is.@yieldand@section,@stackand@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@sectionor@pushunder 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@phpblock, or@verbatimcompletes 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@verbatimblock 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 asroute('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 conventionalroutes/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, theRedirect,URL, andResponsefacades, theredirect()/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 throughview()andmarkdown()the way a mailable does,Lang::hasForLocale()names a translation key, andConfig::getMany()names as many config keys as its array holds. - Environment variables are indexed like every other Laravel string key.
env('APP_NAME')andIlluminate\Support\Env::get()now complete from the project's.envand.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 insideconfig/*.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.envproves 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()andapiResource()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
$signaturegrammar 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 abool,{--since=}a?string,{tags*}alist<string>) rather than by the union of every shape a console parameter can take, and the parameter array ofArtisan::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'sconfig/*.phpfiles 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(), andstorage_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(), acan:ability,Modelmiddleware parameter, and Blade's@can/@cannot/@cananynow completes, hovers, navigates, and is checked. Abilities come fromGate::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/$singletonsarrays) is now indexed, soapp('sentry')andresolve('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 ingetFacadeAccessor(), and a facade written by hand ships no generated@methoddocblock 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/schemaand 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 LaravelConnectionandTableattributes, 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 withBlueprint::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}()andfor{Relationship}()methods Laravel resolves throughFactory::__call()now complete, hover, and chain, one per relationship on the associated model, alongsidetrashed()for a model usingSoftDeletes. The count travels with the factory too, socount(3),times(3), andfactory(3)switchcreate()andmake()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 barearrayit is declared as:$data['title']is astring, anullablefield addsnull, one that is neither required nor nullable becomes an optional key,'items.*.id'becomeslist<array{id: int}>, and animagerule gives you a realUploadedFile. An enum rule types its field as the enum's backing type. Rules reached througharray_merge(), the parent chain, or a trait are all followed. Contributed by @shuvroroy (#292, #294, #307). - Higher-order Laravel collection proxies.
$users->map->emailis 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 returningstaticstays on the Eloquent or application-defined collection it came from. Contributed by @shuvroroy (#314). - Eloquent
$pivoton many-to-many related models. A model reached through abelongsToManyormorphToManyrelationship now exposes$pivot, so the intermediate row completes, hovers, and resolves. The type comes from the relationship'sTPivotModelgeneric, then a->using()call, then the basePivot, and the relationship's->withPivot()columns are shown on hover. Contributed by @shuvroroy (#266). - Eloquent morph map aliases. A
Relation::morphMap([…])orenforceMorphMap([…])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'sdisk(),drive(),cloud(), andbuild()declare only theFilesystemcontract, so adapter-only members such asassertExists()anddownload()were reported missing.config/filesystems.phpis now read to see what each disk is really built from, and a disk on a custom driver is resolved through theStorage::extend()registration in your service providers rather than costing every other disk its type.- Laravel and Carbon macros registered with
mixin(). AStr::mixin(new StrMixin())orCollection::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-basedmixin()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
readonlywrites and self-contradicting docblocks. A write to areadonlyproperty from anywhere PHP forbids one, and a@paramor@returntag 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(), aforeachor 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
staticfrom an inherited return type, an abstract trait method nothing implements (with the "Implement missing methods" code action stubbing it alongside the rest), and amatcharm whose literal can never equal the subject. Contributed by @calebdw.
Type inference¶
preg_match()fills$matcheswith the keys the pattern actually has. A literal pattern now gives the result an array shape: key0for 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_CAPTUREandPREG_UNMATCHED_AS_NULLare honoured, andpreg_match_all()reads the same way, holding each group as a list or one shape per match underPREG_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 astring, 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-outand@phpstan-self-outsay 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-implementscontributes to trait$thisresolution. A trait annotated with the tag now resolves$thisagainst the required interface inside its own methods, matching the existing@phpstan-require-extendsbehaviour, 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,$, orconst, 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, carryreadonlythrough, and skipfinaland 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], theirUsescounterparts, or the older@covers,@uses, and@coversDefaultClassannotations, 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, anduse constapart, 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.tomlnow supports[semantic_tokens] mode = "contextual" | "full" | "off". The defaultcontextualmode emits only context-sensitive highlighting that complements editor syntax grammars, whilefullkeeps the previous broad stream andoffdisables semantic tokens. Contributed by @calebdw. @phpstan-ignoreidentifiers 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 --globalcreates a config in your platform's config directory (~/.config/phpantom_lsp/.phpantom.tomlon 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.tomlper 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.tomlnow 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 analyzenow supports PHPStan-style--debugand-v/-vv/-vvv.--debugprints each file as it is analyzed and disables the progress bar, so a hang is immediately attributable to a specific file;-vadds per-file durations and a phase summary,-vvadds worker ids and parse tracing, and-vvvadds 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-extendsare 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-typealias or a@methodparameter 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
vardetail 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 forhtmlspecialchars()/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
stringparameter, every?Carbonproperty, everyCollection<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
.pharis 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.jsona 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-stringacceptance check, andmodel-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/?objectused 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 anassert()or@phpstan-assert/@psalm-assertcall, even for statements that could never be one. Non-call statements now skip that work entirely. @methodand@propertytags 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
analyzeandfixCLI 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$commenttruncated the new line into$author = $->createdByUser;, mirroring a deletion the user never intended to repeat. UsetextDocument/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 toe(), which is not written anywhere in the template, and hovering the{{/}}itself already reflected that by describing the implicite()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 asroute(...)in{{ route('pages.index') }}. It now targetse()too. - A method chain no longer resolves against another file's
useimport. The cache that reuses a shared chain prefix (Pen::make()inPen::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$panelIdis not known until runtime. With no known prefix to anchor a check against, everyroute()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 likeroute('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, sounset($config['driver'])on anon-empty-arrayor a shape with a requireddriverkey left that guarantee in place. Aforeachreading 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 benulleven 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 thenon-empty-array/non-empty-listguarantee, so the loop is correctly treated as possibly not running. - A class's own
offsetGet()is trusted overArrayAccess's own docblock.ArrayAccessdocumentsoffsetGet()as returningTValue, a placeholder name that only means something once a class writes@implements ArrayAccess<TKey, TValue>to bind it. A class that implementsArrayAccessnatively, with no generics at all, still had that placeholder leak in as if it were a real class namedTValue, so$pens[0]->write()reportedwriteas unresolvable even thoughoffsetGet(): Penwas declared right there.$pens[0]now resolves to what the class's ownoffsetGet()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 bemixedeverywhere, 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, soSudo::fetchProperty($config, 'shell')resolves to the typeConfiguration::$shelldeclares, 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 = trueunder[diagnostics]was only read at startup, so enabling it in a project's.phpantom.tomlor 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 = falsemid-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()andhelper()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_matchresult keeps the groups it matched.preg_matchwrites its capture groups into an out-parameter, and a literal pattern says which keys that leaves behind, so a group read resolves to astring. 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 asnulland 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, soif ($ok)narrows the array to the pattern's keys and theelsebranch to the empty one, exactly as testing the call itself does. This applies to by-reference output parameters generally, not justpreg_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()instantiatesWidget, 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 theuseimports, 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 tonewexpressions 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 ahelper()call insidenamespace App;was credited toApp\helperalone 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 ause functionimport still counts only towards what it imports. - Renaming a
define()-declared constant rewrites thedefine()call too. The name adefine('FOO', 1)call declares is a string literal, and nothing recognised it as naming the constant it creates. RenamingFOOfrom any use site therefore rewrote every use and left thedefine()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 thedefinekeyword. - Renaming a constant now rewrites
defined()andconstant()calls too.defined('FOO')andconstant('FOO')name a constant through a string literal the same waydefine()does, and nothing recognised either as a reference to it: renamingFOOrewrote the declaration and every ordinary use but left these calls asking about the old name, so adefined()guard silently stopped guarding andconstant()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 bareReflectionPropertyandgetValue()a baremixed, which is as specific as an annotation can be: what the read produces depends on the name passed togetProperty(), 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?Shellit is, and a member reached through it counts as a reference to that member.new ReflectionObject($x)also keeps the class it reflects, the waynew ReflectionClass($x)already did, sonewInstance()on it no longer widens toobject. A name that is not a literal, a property with no declared type, and a reflected value whose class is unknown all keepmixed. - 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\VERSIONalso rewrote an unrelatedApp\B\VERSIONdeclared 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 anobject|stringparameter. The third argument tois_a()means the check also passes when$xis aclass-string<Foo>, not just an instance, but a successful check replaced the whole subject type withFooalone whenever nothing in the union already named a class. A route parameter typedobject|stringcame out of the check as plainFoo, so a nestedis_string($x)guard written to handle the class-string case was reported as always false. The check now narrows toFoo|class-string<Foo>, keeping both halves the third argument actually allows.get_class($v) !== Foo::classkeeps the subclasses it lets through. A negated exact-class check was applied as if it were!($v instanceof Foo), so every subclass ofFoowas ruled out along withFooitself. A subclass'sget_class()names the subclass, so it passes the comparison and belongs in the result: filtering aDog|Puppy|Catlist onget_class($v) !== Dog::classnow keepsPuppyinstead of reportingCatalone.- An
instanceofcheck keeps the type arguments of what it narrows. Narrowing a union member that already named the checked class replaced it with the bare class, soarray_filter($items, fn ($v) => $v instanceof Collection)on aCollection<User>|stringlist came out asCollectionand 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$vis null. Loose equality also matches'',0and[], which is why$v == nullnarrows nothing, but the negated spelling of the same comparison was treated as the strict one and narrowed the value tonull. 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 ongetanswered nothing, its unknown-method and argument-count checks never ran, and hover showed the static property as an instance one. Onlyparentis 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, andenum-stringwere not recognised as refinements ofstring, so a@paramcarrying one was thrown away in favour of the barestringthe 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-stringalso no longer accepts thenon-empty-…refinements, each of which still admits the falsy"0". - A literal string satisfying
lowercase-stringoruppercase-stringis no longer rejected. Recognising those refinements (above) exposed a gap right behind it: a string literal compared againstlowercase-string,uppercase-string, eithernon-empty-variant, orcallable-stringmatched none of the literal-value rules and fell through to "not a subtype", so'abc'failed alowercase-stringparameter and'strlen'failed acallable-stringone, the opposite of what both refinements allow. A literal now satisfieslowercase-string/uppercase-stringexactly when it has no cased character the wrong way (so'123'and''satisfy both), and acallable-stringliteral 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()andnew static(). Finding references on a__constructdeclaration foundnew Foo()call sites but skipped instantiations written through theself,static, andparentkeywords, 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 wayself::__construct()already was, without also double-counting the same keyword when it is the subject of a static call rather than the operand ofnew. - A Laravel project that requires Larastan gets PHPStan diagnostics too. PHPStan auto-detection looked only for a direct
phpstan/phpstandependency incomposer.json, so a project that requireslarastan/larastanand lets it pullphpstan/phpstanin transitively never hadvendor/bin/phpstanrecognised, even though the binary was right there. A Laravel project is now recognised through a direct dependency onlarastan/larastan, or a fork of it such ascalebdw/larastan, instead: plain PHPStan does not understand Eloquent magic, facades, or container bindings, so a Laravel project that depends onphpstan/phpstandirectly 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.neongets PHPStan diagnostics regardless of whatcomposer.jsondeclares. PHPStan auto-detection depended entirely on acomposer.jsondependency, so a project that hand-authors aphpstan.neonorphpstan.neon.distconfig, whether it depends onphpstan/phpstantransitively, installs a Larastan fork the dependency check does not otherwise certify, or wires PHPStan up some other way entirely, never hadvendor/bin/phpstanrecognised. Aphpstan.neon/phpstan.neon.distfile 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.xmlgets phpcbf as its formatter, even whensquizlabs/php_codesnifferis only a transitive dependency. Formatter auto-detection looked only for a directsquizlabs/php_codesnifferentry inrequire-dev, so a project that instead depends on a rules package likeslevomat/coding-standardorcakephp/cakephp-codesniffer, which pull PHP_CodeSniffer in transitively, never hadvendor/bin/phpcbfrecognised even though the binary was right there. Aphpcs.xml,.phpcs.xml,phpcs.xml.dist, or.phpcs.xml.distfile 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.xmlhanded phpcbf the job even on a project whosemago.tomlsays in as many words what it formats with, and every save reformatted the file to the PHPCS ruleset. A[formatter]table in the workspacemago.tomlnow settles it: the ruleset records what the project lints with, that table records what it formats with, and both are honoured. - An
instanceofcheck on a value declaredobject|stringnarrows 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 typedobject|stringcame out ofif ($server instanceof Server)asobject|string|Serverand passing it to something expecting aServerwas reported asstring does not satisfy Server. Both spellings of the check were affected, including the guard formif (! $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 baseModel, or reportedsubject 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/@extendson 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 offirstOrFail(),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, whereUserBuilder extends Builder) lost the model one level in, since neither of its own classes declares generics, and the query fell back to the baseModelthe 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 resolvesfirst(),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, soarray_filter($values, fn ($v) => $v !== null)still looked like it could holdnulland returning it from a function declaredint[]was reported as a type error. The surviving values are now narrowed the way the body of anifnarrows the variable it guards, whether the test is written as anis_…()call, a comparison againstnull, aninstanceofcheck, 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 underARRAY_FILTER_USE_BOTH. Closes #376.- A filtered
listis no longer reported as alist.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 reportsarray<int, T>, while the functions that renumber (array_values(),array_merge()) go on answeringlist<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 $requestmethod above aPendingRequest $requestmethod reportedIlluminate\Http\Request::get is deprecatedon the HTTP client call, whereget()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, andinstanceofbranch 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
@templatedeclares.@template TAsync of bool = falsesays that a use of the class without a generic argument meansfalse, 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:PendingRequestdeclares synchronous mode as its default, so an ordinaryHttp::get()looked like it returned a promise rather than aResponse, andjson()along with the rest of the response API appeared to be missing on it. A plain request now resolves toResponsewhether it is made throughPendingRequest, the client factory, or theHttpfacade, andasync()still resolves toPromiseInterfacethrough all three. Contributed by @shuvroroy (#377). - A conditional assertion narrows the value the call was written on.
if (filled($search))left a?stringnullable inside the branch, so passing it on to something that expects astringwas reported as an error. Two things stood in the way, and Laravel'sfilled()andblank()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 asnumeric|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 withRoute::name($panelId . '.')) registers routes whose names cannot be enumerated statically. Anyroute()call whose name falls under the known static prefix of such a group was incorrectly reported asUnknown 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 formRoute::group(['as' => $dynamic], …), still had everyroute()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: hoveringfunction helper()repeated its signature, hoveringconst 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
getorsethook was walked, so a method call, property access, or any other navigable expression written there was invisible: go-to-definition onget => $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,$thisis the class that declares the hook, a local assigned earlier in a block-bodied hook keeps its type, and asethook's$valueresolves 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 bothMember 'label' not foundandMethod 'get' not foundon the parent class. Neither half is static, so the property now resolves as the instance property it is and theget/setaccessor 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 plainechowith noe()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(),__(), ortrans()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 aconfig/*.phpfile 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
/varand/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, soanalyzereadvendor/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'sview()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. Acomposer installrun while the editor is already open registers the newvendor/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(orconfig/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 keyedfile()call resolved as though it named no field at all and a default-onlyheader()call resolved as though its default text were the key.header(),query(),cookie(),input(),post(), andfile()now bind a named argument to the parameter it actually names, including on an app's ownFormRequestsubclass, 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()andurl(null)still resolve to the generator, while a non-null path now resolves tostringconsistently 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 namedBARtoo, since the check only compared short names. Renaming the global constant rewroteHolder::BAR's declaration in an unrelated class while leaving everyHolder::BARuse 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: falsestill returned theconst 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
$modelproperty 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 baseModelin the type engine. A::classconstant or string literal assigned to$modelis now the associated model for inheritedmakeOne()/createOne(), count-dependentmake()/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$modelotherwise outranks the convention just as it does at runtime. A nullable value passed tocount()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, soreturn __LINE__ + 3;in a function declaredintwas reported as returningint|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, anintfor__LINE__and a string for the others, so arithmetic on a line number stays anintand hover reads them the way it reads any other value.__CLASS__keeps the class it names, the wayFoo::classdoes, so a name captured from it still works where aclass-stringis 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 aforeachis a sentinel for the first iteration to replace, and every path through the body replaced it, but the type after the loop still carried thenullas though the body might not have run at all. Handing the result to anything that wanted anintwas 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, anon-empty-arrayannotation, 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$userkept its nullable type from there on: its members were reported unknown, completion offered nothing, and passing it to anything that wanted aUserwas 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 anifcondition does, so every guard form is honoured in both places: a null check, aninstanceof, a type guard such asis_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 asstring|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 anecho, and an echoed expression was the one place a ternary, an&&chain, or amatch (true)proved nothing, so a template's guards were ignored while the identical line written as an assignment or areturnnarrowed correctly. Plain PHP that echoes a guarded expression is fixed with it. - A write to a
foreachvalue variable stays in the iteration that made it. A loop that ends with$step['key'] = $decodedfed 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 anisset(...) && 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 destructuringforeachbinds) 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 equivalentifnarrowed correctly, and so did aninstanceofarm, because match arms had their own small narrowing pass that knew aboutinstanceofand nothing else. Every arm now runs the same condition pipeline anifbody 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, sodefault => $xreads what a preceding$x === nullarm 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$parentis not null: the chain would otherwise holdnull, which is never identical to a value whose type excludes it. Returning$parentfrom 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 thefalsehalf of what it stores.$files[] = realpath($path)recorded the element asstring|boolrather thanstring|false, because the append boundary treatedfalseas incidental precision and widened it the way it widens a literal string or int. Widening a boolean half invents the other one, so anassertNotFalse()on an element then strippedfalsefrombooland left atruethe 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
@varcast abovereturnis honoured./** @var int */written directly abovereturn 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
objectand that aTraversableisiterable. Those facts, together with the equivalence between the ways one array type can be written (array<int, Cat>,list<Cat>, andCat[]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 aCollection<User>was not anobjectand anArrayIteratorwas notiterable, 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 benullno longer satisfies anobjectoriterableparameter, 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
thrownarrows its branches.throw new RuntimeException($model ? get_class($model) : '')still read$modelas 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 areturn, an assignment, or a call argument narrowed correctly. A thrown value is walked like any other expression now. - A plain function's
@returndocblock 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 thearraywritten 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 thoughCarbon::create()returns?Carbon. The pass that works out what a@templatebinds to reads its argument as text, and that reading answers with the classes an expression can be, so thenullarm was dropped and the template bound a plainCarbon. 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,nulland 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 pastg3into its docblock and handedg4(Status $s)the@param array<Status> $swritten for a different function entirely, reporting a type error against a type the parameter never had. The scan now also recognises afunctionkeyword 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 repeatedcurrentUser()orSession::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 byif (!$period instanceof Period) { return; }proves$agreementwas 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$agreementon 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->altas 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
@vardocblock 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, soassertInstanceOf(MethodNode::class, $mock)on aMockObjectleaves aMethodNode&MockObject. It was recorded asMockObject|MethodNodeinstead, 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)&MockObjectproven to be aMethodNodebecomesMethodNode&MockObjectrather than staying as it was.- An argument is not checked against a
@templateonly 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'stravelTonamesTDatein both$dateand its optional$callback, and a call passing only the date still bindsTDatefrom 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 aCarbonand getting a?Carbon, contradicting the callee's own@template TDate of …|nullbound. What counts is now the binding sites the caller actually filled. - A
@phpstan-assert-if-truepromise about the receiver's own members is kept. PHPStan'sScope::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!nullpromise 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 matchingis_*()guard would apply it. PHPStan leaves the identicalisInClass()bare, so that pairing is supplied for it: extensions are written against it regardless. - A
Stringableobject passed to astringparameter is checked against the file'sstrict_typessetting. PHP only converts aStringableobject to a string automatically outsidedeclare(strict_types=1); under strict types the same call throws aTypeError. Every neighbouring type-juggling rule (int/float to string, numeric-string to int/float) already read the file'sstrict_typesflag, but theStringablerule 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 ofarray_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 toint|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$chunka 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_keysasks for the original keys back.max()andmin()answer with the values they compare. Both weremixedfor every call, which accepts anything:takesInt(max("a", "b"))andtakesInt(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, withtrueandfalsekeeping their own type so a later?: 0orassert($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 toint|string, because the shared expression resolver had no answer for$a - 1 - $bat all outside of assignment tracking, so a call argument built from arithmetic fell back to nothing rather than toint. Arithmetic, comparison, bitwise, and spaceship operators are now resolved by the one pipeline every consumer shares, so the key staysintwhether 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 anarray<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?boolsharing 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()andkey()drop thenullan empty array would give. Thenullonly 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 anon-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, soarray_map('intval', $ids)fell back to a barearraywherearray_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 withoutnulland 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|floatresolves to one class and one scalar, and the type engine kept the pair on a single entry that only named the class. Aninstanceofcheck 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: theelseofif ($value instanceof Decimal)still readDecimal|floatand passing$valuetonumber_format()was reported as a type error, as was the body ofif (!$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 astring|false, orif (!is_array($status)) { $status = [$status]; }on a value that may or may not already be a list. After theif, every path holds the good value, but the merge put the original union back and the line below was reported for afalseor a bare item that cannot reach it. Two things caused it. The path where the check did not hold only ruled outnull, where the same guard written asif (!$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']insideif (is_array($violationMessage))), an argument to one of the array functions whose result follows its input (array_slice($cached, 0, $limit)insideif ($cached !== null)), and the same read after a@phpstan-assertguard such as PHPUnit'sassertNotNull(). Each of those resolution paths consulted the backward@param/@varscan 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 ajson_encode($value, self::DEFAULT_OPTIONS)reported asstring|falsewhen the mask it is handed setsJSON_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, andMatrix::TYPED->valuewere all reported asProperty 'value' not found on class 'Matrix'forpublic const Kind TYPED = Kind::A;andpublic 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 offClass::CONSTnow resolves against what the constant's value or declared type actually is, so an enum case stashed in a constant still exposes->valuewhether the constant is typed, untyped, or read throughself::,static::, or the class name directly. - An
array<T>orT[]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 wantingarray<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-outarray<string|int, string>already did, and the result carries that key type through an array union (+) with other string-keyed arrays. Alist<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 toarray<int|string, …>, so returning it from a function declaredarray<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())answeredlist<array-key>wherearray_keys($templates)on a local holding the same call answeredlist<string>. The forward walker records a placeholder for a call it has not resolved yet, and that placeholder was read asmixed, 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 asmixed. isset()proves a chained array key present through a variable index.if (!isset($state['files'][$path]['violations'])) { return; }left the optionalviolationskey 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 anif,while, orforcondition or areturnvalue. 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 acallable, sospl_autoload_register(function ($class) { … })left$classwith no type at all. Every string builtin applied to it then answered for an argument of any type:str_replace('App\\', '', $class)came back asarray|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, ornullis unaffected. ReflectionClass::newInstanceArgs()returns an instance rather than a maybe-instance. It builds the same objectnewInstance()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 returningNode|nullwhere it declaresNode, and the same call written asnewInstance($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 awaynullandfalseand 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 leavesstring|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 toexplode()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, and0.0. What is only sometimes false is kept, so a plainstringorintstill spans both, and an array shape with a field it must have is truthy and stays. The same rule already decided whether a@phpstan-assertguard 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} $configwas accepted silently, whiletakesConfig(['host' => 'localhost'])correctly reported the missingportkey. 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
namespaceblock in a file is checked like the first one. PHP lets one file declare several namespaces, and everything after the secondnamespaceline 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, andFoo::$xwere 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?Repoat 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 anif, 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
trysurvives acatchthat rethrows. Acatchthat throws or returns never reaches the code after thetry, but the state it left was merged in anyway, so it put back the type a variable had before thetrybody assigned it.try { $h = new Holder(); } catch (RuntimeException) { throw new LogicException(…); }left$hnullable afterwards even though the only path that reaches there assigns it. Acatchthat falls through still contributes its state, as it should. - An
&&chain inside amatcharm narrows its own operands.match ($kind) { 1 => $this->a && $this->b && $this->same($this->a), … }checked the last operand against the type$this->ahad before the chain started, so a null the chain's own first operand rules out was reported. The same chain written as areturnstatement 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?Landat 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 aconst NONE = null;still proves nothing. - A loop condition narrows its own operands. The
&&narrowing anifcondition performs was not applied in ado/whileorforcondition, sodo { $node = $this->parseOptional(); } while ($node && $this->addChild($list, $node));reported the null the condition's first operand rules out. Both now narrow asifandwhilealready did. - A namespaced constant is found however it is written. A
constdeclared 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 ause constimport all resolved to nothing: hover showed no value, Ctrl+Click went nowhere, and everything the constant's value proves was lost, so a strictin_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. Adefine()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.
?stringandstring|nulldescribe the same value, but only the second was ever checked: passing a?stringto astringparameter, returning one from astringfunction, and assigning one into astringproperty 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 listsnullamong 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 toneware now narrowed like the arguments to any other call. array_filterkeeps what its callback proves about the keys. In the two modes that hand the callback the key (ARRAY_FILTER_USE_KEYandARRAY_FILTER_USE_BOTH) the result was reported with the key type it went in with, soarray_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 declaredarray<string, …>was reported as a mismatch. The keys that survive are now read off the callback the same way anif (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[] = $penon aarray{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 alist. Both now keep the array's key and value types and fold the written pair into them. - An array shape answers the
listandnon-empty-arraypromises from its own entries. A shape was compared against those types by name alone, soarray{}satisfied anon-empty-arrayparameter andarray{name: string}satisfied alist, while a real list of values written asarray{string, int}did not satisfylist. A shape is now non-empty when it names a key that is always there, and a list when its keys run0, 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 sameRuleViolation|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> $valueswent 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 asarray{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 anarray<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 declaredarray<class-string, …>. PHP coercesnull,trueandfalsebefore using them as keys, so those now land on the'',1and0entries they index at runtime. (object) []is astdClass. Casting an empty array to an object produced anobject{}, a shape with no properties, which nothing else in the engine produces and which was rejected by every parameter declaredstdClass. It is now thestdClassPHP builds. A cast of a non-empty array still keeps the properties it names.- Reading a key a shape marks optional carries the
nulla missing offset yields. A@var array{file: string, type?: string}says thetypeentry may not be there, but reading it produced the samestringa 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 theisset()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, amatch (true)arm) as much as in anifbody, 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][] = $rowtold 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 emptyarray{}, 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 witharray<int, list<Row>>and$byLetter['all'][] = $wordkeeps its shape entry asarray{all: list<string>}. A push onto a value that is not an array is left alone, so$collection[] = $itemon anArrayAccessobject stays that object rather than turning into a list. - An array union keeps the keys of both sides.
$config += ['slot' => $default]and$merged = $defaults + $overridescollapsed to a barearray, 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>andarray<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 alist<T>never satisfied anarray<int, T>, anarray<V>never satisfied anarray<array-key, V>, and a list of array shapes was rejected by a parameter declaredarray<int, array<string, mixed>>. Each spelling now contributes the key and value type it implies before the two are compared: alistkeys onint, a one-argumentarrayonarray-key, and a one-argumentiterablepromises nothing about its keys at all. Aliston the receiving end still demands sequential keys, so a plainarraydoes 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 baseModel; a factory reached correctly fromDraft::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$useras possibly null inside the body, whileif ($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@unlesscompiles toif (!…), 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 declaredarray|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-purepromises it changed nothing, so the proof survives it. iterableis a type guard.is_iterable($x)narrowed nothing, and neither did the@phpstan-assert iterabletag PHPUnit'sassertIsIterable()carries:iterablenames no class, so the instanceof route could not carry it, and the guard route had no kind for it the way it has one forarray,string, andcallable. Both now narrow. A union keeps the membersforeachcan actually walk, which is an array in any of its spellings plus an object whose interfaces reachTraversable, 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 amixedthat passes the check reads asiterablerather than stayingmixed.- An assertion about
$thisnarrows it, even where a docblock rebinds the closure. A Pest test closure is bound to whateverpest()->extends(…)names, and no expression in the test file says what that is, so the suites writeassert($this instanceof AppTestCase);as the closure's first line to spell it out. The@param-closure-thistag on Pest'stest()had the last word on what$thiswas, 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$thisa 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;andconst COMBO = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR;were read only as far as their type, a plainint, so everything built on one lost the value behind it:json_encode($data, self::FLAGS)was still reported as possiblyfalseeven though the flag rules that out, and amatchor 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, sounknown_classwas 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$datathrough it. Contributed by @HelgeSverre (#352). - A
@seetag that carries prose or a naming suggestion is no longer reported as a missing symbol.@seelegally 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 3reported a missing functiontheand@see ShortWidget as a potential shorter namereported 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@seetarget 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@coversand@usesare 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
neverbranch 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. Aneverbranch 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 subtractionif (!$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 $actualtag (PHPUnit'sassertIsResource()) 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 carryresourceornull, and only the scalar types had a route to the check thatis_resource()andis_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 alist<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 barearray, soforeach (self::APPROVED as $address)andself::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 innermb_strpos()asint|falseall 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')) : nulland everystrpos/array_search/getenvguard 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()andfile()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 backstring|array|nulleven 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 aphotos[]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 byIp\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 severalnamespaceblocks rather than assuming the whole file lives in the first one. Contributed by @petrovo-as. - A
use functionoruse constimport 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 globalconst 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 thebar()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 leftuse function baz;, dropping the namespace. An aliased import fared worse.use function Foo\bar as quux;becameuse function baz as quux;and everyquux()in the file was rewritten tobaz(), 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'sHttpKernelwas dropped along with everything else undervendor/. 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-declaredabstractstill 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,Countableand 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$matcheswhenever 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 asstring|nulland 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, acrosspreg_match,preg_match_all,parse_str,execand 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()andkey()all describe the caller's own key or value type, but PHP signatures cannot say that, so the stubs spell outint[]|string[]andstring|int|falseand every call carried a branch it could never take:array_keys()on a string-keyed array reported a list of ints as a possibility, andarray_search()reported an int key. Each now reports the key or value type of the array it was given, andarray_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, anarray-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 alist<User>but dropped alist<string>back to a barearray, 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, soarray_pop()on alist<string>is astring, notmixed. array_sum()andarray_product()over an array of ints report an int. Both are declaredint|floatfor 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
@templatenamed in several alternatives of a union@parambinds from the one the argument matches. An annotation such as@param Collection<TKey, TValue>|array<TKey, TValue> $itemsoffers 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
ifcontributes 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 inif ($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: afterif ($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 astdClass, 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 theifis the union of what its branches end with. - A
breakcarries its state out of the loop. The value a variable held when abreakleft 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 (…);reported1rather than'x'|1, and abreakout of an inner loop lost the assignment it had just made. Everybreakis now an exit of the loop it names, and joins the code after it alongside the ordinary fall-through. Abreakinside aswitchreads the same way, so an arm that leaves early contributes what it leaves with. - An inline
@vardescribes the assignment it is written above, not every later read./** @var null|list<…> $cached */ $cached = Cache::get(…);followed by an ordinaryif ($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
__()andtrans()report the line they resolve to. Laravel declares the translation helpers asstring|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 anHtmlString, andassertSee(__('key'))mismatched against the string it really is. The key at the call site settles it now: a key naming a single line is astring, a key naming a group is the array of lines beneath it, and__()with no key at all is thenullit 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
@propertytag's name is coloured as a property. The member name in a@propertytag 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
instanceofcheck narrows the value down to the class, not out to a wider union.assert($obj instanceof Configuration)on a value declaredobject|nullreportedobject|null|Configuration: the check added the class beside what was already there instead of ruling out everything the class does not cover. Theif (!$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 againstnull, 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->versionon a plainstdClassyields no type at all, and a followingassert(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 anassert()and past anif (!is_string($v)) { return; }guard alike. - A null check on an array element refines that element.
isset($m[0]),$m[0] !== nullandassert(isset($m[0]))left a following$m[0]readingstring|nullon anarray<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)reportedCollection<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 coversis 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, andis not nulltakes 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'sData::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 bareTValue. - 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)andmicrotime(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 anintkept afloat, andSimpleXMLElement::asXML()reported aboolthat 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 namingtrueorfalseis told apart from a plainis boolso 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_EOLand 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
@propertytag beats an inherited property nobody can reach. PHP only calls__get()when no accessible property of that name exists, so aprotecteddeclaration up the chain is never what the read yields. PHPantom reported its type anyway: an Eloquent model documenting@property string $connectionstill resolved$model->connectionthroughModel's own\UnitEnum|string|null, and the same happened for$tableand$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) + $nis 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 outint|floatand failed everyarray<string, int>it was declared as. An offset read on[]now yieldsnull, 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 anullfrom the empty half. - A ternary's arms see what its condition proved.
is_string($req) ? $req : 'today'handed both arms the rawstring|array|null, so a value the condition had just established was still reported against everystringthe ternary fed. Each arm is now resolved under its own polarity of the condition, using the same narrowing anif/elsebody 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 onlyinstanceofand 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 theelsebranch, and so do chains of more than two conjuncts.is_resource()joins theis_*family it was missing from, and!== ''/!== []now refine tonon-empty-string/non-empty-arrayrather than only removing a literal that was never in the union. - An array written under a
stringkey stays keyed bystring. Every non-literal string key widened toint|string, on the grounds that a numeric string becomes an int key at runtime. Only a literal decimal-integer string does, so a function buildingarray<string, string>reportedarray<int|string, string>and failed its own declared return type, including after an explicit(string)cast, a backed enum's->value, andReflectionProperty::getName(). A key expression now keeps its own domain:stringstaysstring,intstaysint, and the int conversion applies to literal decimal keys alone.++$iand$i++resolve as well, so a counter used as a write key no longer falls back toarray-key. - An assignment in an
elseifcondition narrows what it wrote.} elseif ($token = $request->bearerToken()) {puts$tokenin scope and proves it truthy, and the leadingifform read it that way, but theelseifform 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 andelseif:syntax. - A variable seeded with
falsecan 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 firstfalsetoboolthe moment it was assigned.boolincludestrue, so the truthiness check had nothing to subtract and the guarded body still sawbool|int, which was then reported againstdate()'s?intparameter. A writtentrueorfalsenow keeps its own type the way every other literal does, so the join isint|falseand the check clears thefalsehalf. A value that genuinely isboolnarrows totrueinside 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 tobool: an inferred return type suggests: boolrather than thetruethat 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>|stringwith an implementation declaring: arrayreported thestringhalf as a possible result, and that half then failed everyarrayparameter 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, oriterableon either side rules nothing out. - An arrow function keeps its parameters in the type it produces.
fn (BrandView $view) => $this->attrs($view)was inferred asClosure(): array<…>, with the arity and the parameter type dropped, so passing it where aClosure(BrandView): array<…>was declared was reported as a mismatch. Closure and arrow function literals now carry their declared parameters, with an untyped parameter contributingmixed, which any expected parameter type still satisfies. ctype_digit()anddefine()accept what PHP accepts. The bundled stubs typectype_digit()and the rest of thectype_*family as taking astring, anddefine()'s$valuewith the scalar-or-array union it had before PHP 7. php-src saysmixedfor both, soctype_digit($count)anddefine('STDERR', fopen('php://stderr', 'wb'))were reported as type errors on code that runs fine. Both are widened back. Passing an int to actype_*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)&MockObjectwithout them producedFunctionNode|MethodNode&MockObject, which reads asFunctionNode|(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|stringandnull|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 onboolrather than listingtrue|false. - A guard that exits by calling a
nevermethod ends the branch whatever the call is written on. A guard body whose only statement is a call to a method declarednevercannot fall through, so the code after theifsees 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 writtenapp()->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 Laravelif (!$file instanceof UploadedFile) { app()->abort(422); }guard is theUploadedFilethe guard proved it to be. - An
instanceofcheck 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'sRequest::file()returningUploadedFile|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 correctif (!$file instanceof UploadedFile) { throw … }guard, or inside a plainif ($file instanceof UploadedFile) { … }, passing the value to a parameter typedUploadedFilewas still reported as passingUploadedFile|array<UploadedFile>|null. A check that concludes what the value is now drops the alternatives it has ruled out, including anullthe guard had already proven impossible. A check that only rules something out is unaffected, so the array half still survives a negatedinstanceofinside 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@templatebinding 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$dataisarray{message: string},preg_replace('/-.*/', '', PHP_VERSION), andpreg_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 (alwaysstring) 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'snative_function_invocationrule enforces, kept the leading backslash in the function name PHPantom compared againstis_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.phpfile is a no-op.textDocument/formattinghanded 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.phpfile (matched the same way completion and hover already recognise Blade, so it also covers a file opened with abladelanguage 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}andlist<string>promise the keys run0, 1, 2, …in that order, which is whatarray_is_list()answerstruefor, and[1 => 'x', 0 => 'y']does not hold. PHPantom accepted it silently: alist{…}was read as thearray{…}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 anarray{…}. 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 iftranslations[0]were a property the class declared. Nothing declares it, so a model that answers any property name at all answered this one withmixed, and because a check's conclusion outranks anything else, every later read of the same expression was judged against thatmixed.$category->translations[0]->namewas 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 anif.assert($handle !== false)is how aT|falsereturn fromfopen(),pg_connect()orfinfo_open()is checked before use, and PHPantom went on reading the value asresource|falsefor the rest of the scope, reporting every use of it as if the assertion were not there. Onlyassert($x instanceof Foo)and the docblock-declared assertions were ever recognised, so a plain comparison narrowed nothing. The condition anassert()carries now goes through the same pipeline aniforwhilecondition does, so every guard form is honoured in both places: a!== nullor!== falsesentinel check, anis_string()oris_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$vis 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 areturn $bin 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 aT|falseproperty is checked before use, and PHPantom went on reading the property asstring|falsefor 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 areturn, athrow, or acontinue.!$this->handleandempty($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 toint|float|stringthe 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 ofnumericwas reported, even though every entry of the array is numeric on its own, and the same held for a literal key:$values[2]read asstringrather than as the numeric'123'written at that position. The values a literal names now survive into its type, so a read off it, aforeachover it, and an inferred@returnall 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 plainstringwith two string literals stayslist<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@templatebindings, which a function that declares no@templatenever 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 beint|stringwas read asmixed. 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@returnnaming the table is held to what the table holds, so returning a key the table does not have is reported where it is written. TheClass::TABLEandself::TABLEspellings 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 ownint|stringstood for every call andtakesInt(lookUp('immutable'))was reported for passing astring. 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. TheClass::TABLEspelling 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, solookUp()fell back to the whole table's value union andtakesInt(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 aslookUp('immutable')does, for a method as much as for a function. - A
forloop'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 holdingnullrather 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
intno longer widens toint|float.int + intisint, and that held for the bare spelling, butstrlen(),count(), and most of the standard library's counting functions are declared with a refinement likeint<0,max>rather than plainint, and accumulating one of those ($length += strlen($text);) read as an unrecognised operand and fell back to the conservativeint|float, reported several lines away at the function'sreturnrather than at the addition that caused it. Everyintrefinement (positive-int,non-negative-int,int<min,max>, and the rest) is now classified asintfor arithmetic, and the same holds forfloat's own refinements. - A
foreachkey is typed from what is being iterated.foreach ($xs as $i => $x)over alist<int>, anint[], or an array shape left$iasint|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 aT[]bind anint, and an array shape binds whichever ofintandstringits own keys are. So an argument check on the key says something, and filling a second array through it ($rows[$i] = …) yieldsarray<int, …>instead of widening the key toint|string. A subject that genuinely says nothing about its keys, a barearrayor an untyped parameter, still leaves the keyint|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 wholestring|falsethe assignment produced, so passing the line to anything that takes astringwas 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())), thenullsentinel, and the same shapes written as anifall follow, the negated guardif (!$row = $query->first()) { throw … }among them. - A read loop keeps the narrowing its condition established.
while ($line !== false) { useString($line); $line = readLine(); }is how everyfgets(),fgetcsv(), andreaddir()loop is written, and the read at the top of the body was judged againststring|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, anddo/whileshare 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 ofifs, 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 declarearray<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 noresourcedeclaration, 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 calledresource, which does not exist either. PHPantom accepted it silently, becauseresourceis 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 asresource. 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 equivalentif ($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.!== falsenarrows inside the branch it guards.fopen(),finfo_open(),strpos(), and every other function that reports failure withfalseare guarded by writingif ($handle !== false), and PHPantom read the body of thatifas though the check were not there: the value stayedT|falseand passing it to anything that takes aTwas reported, on the line the check exists to protect. The check now rulesfalseout for the branch, the way!== nullalready ruled outnull, and it holds for awhilecondition as well as anif. Onlyfalseis ruled out, so aT|false|nullvalue keeps itsnulland is still reported:null !== falseis true.- A docblock can refine one member of a native union.
/** @return false|string */written over abool|stringreturn type says the only boolean it ever hands back isfalse, which is what makes the idiomatic!== falsecheck 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 sawbool|stringno matter what the docblock said. Each member is now checked against the docblock member that narrows it, the same check a lone nativeboolalready passed, sobool→false,int→positive-int, andstring→non-empty-stringall 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$bodyis a string. PHPantom kept every falsy member of the condition in the result anyway, so the variable read asstring|falseand passing it to anything that takes astringwas 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 theT|falseand?Tidioms resolve toT, 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 declaredstring|false, and so are a couple of hundred other builtins whosefalseonly 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 passingstring|falsewhere astringwas 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 readsstring|false, and a caller that does check the branch still narrows through it. The leniency is tied to those specific builtins rather than to|falseat large, so afalsethat carries an answer, asstrpos()'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
instanceofon 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,0andb, 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} $confignames the keys the callee is going to read, buttakesConfig(['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 satisfiesarray{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 $callbacksays what the callee will do with the result, but passingstatic fn (int $v): int => $vwas 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 bareClosureor a callable named by a string, still says nothing to contradict and is left alone. Parameter types are not compared yet. This also correctedarray_filter's callback, which PHPantom typed as returningbool: PHP tests the result for truthiness, so the everydayarray_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'smodel-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()), wheregives()returns1|99andacceptsLevel()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 anint|stringvalue passed whereintis declared is reported too, matching theTypeErrorPHP raises for the string case understrict_types. Getting there without new false positives meant closing three narrowing gaps the old laxness had been quietly covering for: anelseif's own condition no longer sees a reassignment made in the precedingif-branch as though it had already run;if ($x === false) { throw …; }now narrowsfalseout of$xthe same way an=== nullguard already did, so the common resource-handle idiom (finfo_open(),pg_connect(), …) resolves to its non-falsetype after the guard; and narrowing a declared class byinstanceofto an unrelated interface it doesn't implement (a mock that is both simultaneously) now produces the intersection the value actually has, rather than aFoo|Barunion 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 infirstValue(['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 plainintinstead of1|10. Passing that on to something declared as1|10was 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 throughkey-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
voidcall gives back is reported. A function or method declaredvoidhands back no value, sotakesString(logRequest($request))is a misreading of the API that PHP 8 covers up by substitutingnullat the call site. Nothing said so: a call to something declaredvoidwas resolved to that substitutednullrather than to thevoidit declares, andvoidwas 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 rejectnull, and stayed silent wherever it accepted one. A call now carries thevoidits 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 avoidcall reads asvoidin hover and reports the member access it cannot answer againstvoidinstead of against anullthe code never wrote.neveris 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 annotatedvoidis left alone too, since nothing produces a value of that type and the annotation is what is wrong there, not the argument. interface-stringis held to naming an interface. The refinement was parsed and displayed, but nothing enforced what it says:interface-stringwas compared as though it wereclass-string, so passing the name of an ordinary class satisfied it, and, in the other direction, passing the name of an interface where aninterface-stringwas declared was reported as a mismatch because aclass-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::classis accepted,SomeClass::classis 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 bareclass-stringsays 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_existsguard, 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 runninganalyzetwice 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
@templatebound from several parameters is what all of them have in common.@param T[] $first, @param T[] $secondstates 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 andTbecame whichever argument happened to resolve last.combine([1, 2], ['a', 'b'])boundTtostringfrom 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, soTthere isint|stringand both arguments satisfy it. An empty array literal contributes nothing to the union rather than dragging the whole template down tonever, 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 aBox<int>is required got as far as the class-hierarchy check, which compares the two by name, decidesBoxis aBox, 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-contravariantdeclaration 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 barename()in a@seetag 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@coverskeeps its own reading, where a bare name means a global function and::namemeans the test class's method.- A check on
$a->valueno longer keeps narrowing it after$aitself is replaced. Aninstanceofon 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$ain between left the check standing, so the code after it resolved$a->valueas 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 toContext::getAll()once the receiver's type was unknown, whatever$contextactually 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 asetContext(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 anew 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 typedItemCollection, a return type spelled without generics: anything that names a generic class rather than instantiating it left its@templateparameters standing, so$items->first(), declared@return TModel|null, resolved to a class calledTModelthat 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@templatedeclares (@template TModel of ItemgivesItem), ormixedwhen it declares none, matching what instantiating the class withnewalready produced. Inside a class's own body its parameters stay in scope, since there a member typedTModelmeans whatever the caller bound it to. analyzeandfixno longer silently drop aPATHargument typed relative to the working directory.phpantom_lsp analyze --project-root conformance conformance/tests/x.php, exactly what shell tab-completion produces, resolvedPATHagainst--project-rootrather 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.PATHis now resolved against the working directory, and a path that still resolves to nothing is an error on stderr with exit code 2 foranalyze(distinct from 1, which means diagnostics were found) or 1 forfix.- 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.phpfile 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 aTextEdit, 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'susestatement, 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} $shapeis a structural constraint, but nothing checked a concrete class or an anonymous(object) [...]literal against it: bothtakesObjectShape(new Reading())andtakesObjectShape((object) ['foo' => 1])were reported as a mismatch even whenReadingdeclarespublic 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, orResourceis no longer read as PHP's scalar alias of the same name. None ofinteger,boolean,double, orresourceis a reserved PHP keyword, so a project may declare a real class with one of these names, the way it already could withNumberorReal. A@param Integer $valueannotation naming that class was resolved as PHP'sintegeralias forintinstead, so passing an actualIntegerinstance 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 bindsTfrom the argument it is called with, but an array literal (['debug' => false, 'verbose' => true]) only ever resolved to the barearraykeyword, soTbound to the erased bound rather than to the literal's own keys, and akey-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.Tnow binds to the literal's actualarray{...}shape when nothing else narrows the argument, sokey-of<T>andvalue-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, ordecimal-int-stringcan 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 asmixedand is not enforced at all, and one whose refinement alone is unmodelled widens to the type it refines, so apure-callableparameter still rejects an argument that is not callable.key-of<…>andvalue-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: avalue-of<array{a: int, b: int}>parameter takes anintand 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
didChangehandler 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-typealiases on traits and enums were flagged as unknown classes. A type alias defined via@phpstan-typeon 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, throughinstanceof,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-keyargument is judged as theint|stringit is. A key read out of aforeachover an array whose key type is unknown isarray-key, and passing one to a function declaringstringwas reported as a mismatch even though the same value written asint|string, or as a plainint, was accepted: outsidedeclare(strict_types=1)PHP coerces the int half to a string, so there is nothing to report.array-keyis 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|nullresolved 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 yieldsstring, and the null half yieldsnull; a branch that still cannot be typed contributesmixedrather than vanishing. Reading an offset off a plain string is typed too, and givesstring. A branch that produces no value at all, such as athrowarm in amatch, is unaffected: it never reaches the union. - An inline
@php(…)no longer hides the rest of the Blade template. Blade spells@phptwo 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@endphpanywhere 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@propsand@awaredeclarations, 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/@cananyand their@elsecan…/@endcan…counterparts,@lang/@endlang/@choice,@unset, and@js/@vite/@viteReactRefresh/@fonts/@ddwere 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@sectionMissingwere 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@endifrather than a directive-specific closer, so the comment also left that@endifdangling with no matchingif, 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'sCollection::flatMap()takes, bound both templates to whatever the closure's own return annotation said. A closure writtenfn ($c): array => arrStr($c)says onlyarray, so the key type and the value type both came back as that barearray, 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 returningarray<int, string>now gives a collection keyed byintholdingstring. 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@vartags carrying the types of the variables the template is passed, and, for a template rendered with$thisbound, 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
@extendsFirstis no longer invisible. Blade picks the first template that exists out of the candidates@extendsFirst(['themes.dark', 'layouts.app'])lists, and@componentFirstdoes the same for a component. PHPantom recognised neither, so a page built that way lost its layout entirely: the layout's@vardeclarations never reached the child, the variables only the layout reads were reported as ones the view has no use for, and a$userpassed to the directive counted as unused. Both directives are now read like the@extendsand@componentthey 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(), andmapWithKeys()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@templateof its own, only an@extendsbinding. AkeyBy()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@extendsbinding, andmapWithKeys()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 aglobaldeclaration 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
@varwhose 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$callbackuntyped and adding a bogus$userto 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$namethat 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 namedDemoinstead 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 truncatingdemo.bakerydown todemo. 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$caseas a barearray, 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 barearraythe 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
Appfacade resolves when it is chained directly.$repo = App::make(EventRepository::class);followed by$repo->getActiveEvents()resolved, but the one-lineApp::make(EventRepository::class)->getActiveEvents()did not, and neither didApp::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@methodtag (which flattens the container's argument-dependent return toobject|mixed) to the concrete class that actually types the call. The chain resolver now makes the same jump, so both spellings resolve, matching theapp()helper. analyzereports 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'sDatePeriodBaseand Symfony's polyfilledRoundingModeare, 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 TValuewith@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 flattenedstatic<…>to a bare class name, dropping the arguments with it. Both now happen the way they already did for an instance method, soWrapper::make(names())->push([1])reports the same argument mismatch that the two-line form does. - A standalone
@vardocblock narrows a call inside the sameecho,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 (anecho, anif, areturn, ...), 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 expectingarray-key|\UnitEnum|nullinstead ofint|null. A call body now resolves to whatever the callee returns, scalars included, the same way a property read or anewexpression 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 astring[]gave a collection ofstring[]rather than ofstring. 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 alist<string>argument binds aTKey/TValuepair 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 expectingint|nullorarray-key|null. A callback writtenstatic fn (…) => …orstatic 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 reportedCannot access method 'method' on type 'string'for both. A::access on a subject whose only possible type isstring(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 typedclass-string<T>goes further:::now resolves againstTitself, 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 anonymousnamespace { ... }block with a named one, as the bundled PDO stub does, labelled the classes in the global block with the sibling namespace.PDOwas reported asPdo\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]->nameresolved, but writing the same thing in one go asiterator_to_array($it)[0]->namedid 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 barearraythe stub declares. Those rules now apply wherever the call appears, so indexing straight intoarray_map(),array_filter(), oriterator_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
arraynarrows to what the call site passes. A callback handed toarray_map(),array_filter(), or any method whose parameter is typedcallable(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, sostatic fn (array $case) => $case[0]->nameover aarray<array{DiscountType, string}>left$case[0]with no type at all and every member reached through it was reported as unverifiable. A barearrayoriterablehint 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(), andApp::resolve()resolve a class-string argument to that class.app(CurrencyHelper::class)andapp()->make(CurrencyHelper::class)already resolved to the concrete class, but theAppfacade 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@methoddocblock tag flattens it to a bareobject|mixed, and the container-binding keyApp::getFacadeAccessor()returns ('app') is registered againstself::classin the framework's own alias table, which PHPantom discarded as unresolvable.App::make()/makeWith()/resolve()now fall through to the realContainer/Applicationdeclaration whenever the facade's own signature does not narrow the return, andself::class/static::classentries 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 asDb::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 !== nullcheck then appeared to narrownullto 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, soif ($isHtml),$isHtml ? … : …, and a!$isHtmlguard clause should all narrow$rawthe 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 == 1only evaluates its right-hand side once$xis known to exist, and!isset($x) || $x == 1likewise, but PHPantom still reported$xas 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 guardingisset()/!isset()is no longer flagged; a plainif (isset($x)) { ... }still leaves$xundefined in the body, sinceisset()alone does not define it.- A
@methodtag no longer overrides a method that really exists. PHP only reaches__call()when no accessible method is found, so a@methodtag 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 inheritedmock()was enough to throw away the framework's precise type, so$this->mock(Client::class)came back as a bareMockery\MockInterfaceand returning it from a helper declaredClient&MockInterfacewas reported as a type error. The real method now wins, and a@methodtag applies only where no such method exists. - A standalone
@varblock 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// shortcomment written under it or anifblock 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
@phpand<?phpregions into the top level of the generated view file, so ause App\Helpers\CurrencyHelper;written in one imports for the whole template. PHPantom never registered those imports, soCurrencyHelper::formatPrice(…)was flagged and, worse, anything assigned from the short name was left untyped, which took every property, loop variable, and@varderived 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. $thisinside 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$thisinsideRoute::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$thisin a macro body, and hover and go-to-definition fell back to the enclosing class. The lookup now lives onBackendand is shared by every consumer.- A Laravel view name written with
/separators resolves. Laravel's view finder acceptsview('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 thesafe()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')andUser /* 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()andAuth::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-argumentauth()returns theFactorycontract, which declares nouser()at all, so every member call on it reported "Method 'user' not found"; the contract now carries the concreteAuthManagerthe container binds to it, whose@mixin Guardforwardsuser()and friends to the default guard. And theAuthfacade declaresuser()only as a@methoddocblock tag, which the model refinement never touched, so it stayed at the bareAuthenticatablecontract; 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 onauth()->user()->emailandAuth::user()->emailall 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, andmodel-propertyparameters 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
useimports instead of the fully qualified name.@param,@return, and inline@varcompletion (and the "Update Docblock to Match Signature" and "Extract Function/Method" code actions) enrich a type with its@templateparameters, 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 ofCollection<TKey, TValue>. Generated types are now shortened through the sameuse-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')returnsCollection<($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'), whereDecimaldeclares a@templateparameter that the constructor does not bind, resolves its template arguments to their declared bounds and becameDecimal<bool>. That type carried only the class's short name, which nothing outside its own namespace can resolve, so passing the value to aDecimal $amountparameter reportedexpects 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])andArtisan::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<?phpblock are all seen for what they are. - Type casts in ternary and conditional branches are now resolved.
$x = isset($a) ? (int) $a : nullinferred onlynullinstead ofint|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
ifis listed in source order. A variable assigned before anifand reassigned inside it hovered as the in-branch type first, so$x = new Foo(); if (…) { $x = new Bar(); }showedBaraboveFoo. Anif/elsewhere 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-thisclosures resolve$thisto the innermost binding. With a closure passed to a call inside another such closure (aRoute::group()holding a nested group, a macro registered inside another registration),$thisin the inner body kept resolving to the outer call's declared type, or fell back to the lexically enclosing class. The innermost@param-closure-thisnow 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::andstatic::inside such a closure follow the same binding. @param-closure-thisis 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,$thisinside a call nested further in fell back to the lexically enclosing class instead of the@param-closure-thistype. Such a closure does not rebind$thisitself, 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(), orFoo::CONSTin 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, soAborter::fail()insidenamespace Appresolved to\Aborterwhenever 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$xunnarrowed 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 areturn,throw, ornever-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, aforeach (… as &$item)value, ause (&$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
neveris now recognized as an unconditional exit. Guard clauses likeif (!$x instanceof Foo) { abort(); }whereabort()is declared with return typenevernow narrow the type after the if block, and an assignment made in the branch is treated as the dead code it is, exactly likereturn,throw,exit, ordie. 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 toExpectation|HigherOrderExpectationat 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 anException,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\ThingreportedMethod '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()->emailnamed the model's property while go-to-definition on that sameemailhad 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 $thisgeneratedpublic function withTitle(string $title): $this, which PHP rejects:$thisis 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@templateparam is no longer emitted as a hint either:@return Tused to generate: T, which PHP reads as a return of the nonexistent classT. Completing an override of a trait method now also restates the trait's docblock-only@paramand@returntypes (and the@templateparams 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
__setno longer overrides what__getreturns. Assigning to a property a class only has through its magic setter ($bag->a = 9on 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 as9instead of theintthe documented__getgives 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__getas they do without the write. A real declared property, an@propertytag, and a dynamic property on a class without__setare 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 ownelsebranch, 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
ifkeeps that type after the block. The lazy-initialisation idiom (if (!$this->instance instanceof Concrete) { $this->instance = …; }) dropped back to the declared property type once theifclosed, 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 mergedChild|Parentunion now collapses toParenteven when one side is nullable. - A short
@implementsargument list binds the value parameter.@implements Bag<User>against an interface declared@template TKey of array-key/@template TValueboundUserto the key parameter while resolving@method/@propertyand 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
finalin 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 onfunctionwhen 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. analyzereports 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_popon a nested array unwraps one level. Popping alist<list<int>>resolved tolist<list<int>>rather thanlist<int>, so iterating the result gavelist<int>where it should giveint. The same applied toarray_shiftand the other element-extracting functions whenever the element type was not itself a class. Popping alist<User>was unaffected.instanceofnarrowing applies inside aforloop's condition.for ($e = $iter->current(); $e instanceof Foo && $e->x; …)did not narrow$efor the rest of the condition, so completion and hover on$e->xsaw the unnarrowed type.ifandwhileconditions already narrowed this way.extendsis no longer offered while declaring an enum. Typingenum Foo extsuggestedextends, which PHP rejects outright for enums; onlyimplementsis 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 = defaultvalue invalidates its cache entry. Editing the default of a template parameter (@template TAsync of bool = falseto= 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.
-$counton anintresolved toint|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
Realis no longer treated asfloat.realwas PHP's pre-8.0 alias forfloat, and accepting it case-insensitively meant a userlandRealclass (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 thenumberpseudo-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
parentparameter type on an inherited method resolves to the right class.parentbinds 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. Bothselfandparentare 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()insidenamespace Siteresolved to a globalEventclass when one existed, so every member of the project'sSite\View\Eventwas 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__-relativerequire_oncechain are indexed. Composerfilesautoload entries that dispatch to their real definitions withrequire_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()insidenamespace Srcresolved to a global classBwhen one existed, so methods declared on the siblingSrc\Bwere 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 ofnew B(). @methodand@propertytags 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@usearguments and the subclass's@extendsarguments. 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,morphEagerToand the rest of the framework's own methods as properties, plushas_many_countand friends as count properties. Only relationships the model itself declares produce properties now. parent::SOME_CONSTANTresolves to a type. A class constant reached through theparentkeyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached throughself,static, or the class name resolved normally. Constants inherited further up the chain resolve throughparent::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@templateis bound solely by the parameter being checked, such asassertSame'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, andparentin a parameter type resolve to a real class. A method declaredcanChangeTo(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.selfon 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 aparentparameter 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
Animalwhere aCatis 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, withinstanceofor amatchon 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 anewCollection()override) rather than the baseIlluminate\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 theIlluminate\Contracts\View\Viewcontract, but Laravel's view factory always builds anIlluminate\View\View. Every Blade component'srender(): Viewsignature 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 asmatch ($node::class) { Foo::class, Bar::class => $this->handle($node), ... }left$nodeat 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
@paramtypes 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@paramdescribes. 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 Basebound through@param array<string, T> $itemsfell back toT = Basewhen 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 asarray<string, array<string, T>>. - A class constant reference keeps its declared literal value.
Foo::STRING_CONSTANTresolved 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 throughself::, inheritance, and globalconst/define()constants, so hover shows the value andmatch/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 : 2resolved to1|2instead of1, andfalse ? 1 : 2resolved to1|2instead of2, 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 resourcephotos.comments, but Laravel treats the slash as a URI prefix and registers the resourcecomments, so completion and go-to-definition offered names (photos.comments.show) that the application does not have. The names are nowcomments.showand the URIphotos/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@endphpin 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/$thisreturn types are no longer flagged when passed where aStringableobject is accepted. Passing a value typedstatic(Foo)or$this(Foo)to astringparameter reported a type mismatch even whenFooimplementsStringable, which PHP accepts by calling__toString(). This hit any use ofSimpleXMLElement, whose magic__getreturnsstatic, so code like(string) $xml->Body->Messagereported a false positive on every argument passed to astringparameter.- 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
@methodand@propertytags 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 namedCollection<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 clickingUserresolved nothing (or the wrong symbol). The PHPStan*wildcard is now read directly by the type grammar rather than rewritten tomixedbeforehand, 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. @methodand@propertytags 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-assertno longer leaks memory. Evaluating a narrowing call such asAssert::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
usestatements. Renaming a namespace segment that is imported with a groupuse(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 likeuse 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
analyzewall 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 thestringbranch 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 asnull. Contributed by @calebdw. - Renaming a constructor-promoted property parameter now cascades to
$this->propusages. Renamingprivate int $someFieldin a constructor's parameter list previously only updated the parameter declaration itself, leaving every$this->someFieldreference 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'scallable(...)/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
@deprecatedor#[Deprecated]now carry deprecation metadata, so usages likeself::Lowcan 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, andClassName::CONSTANTnow emit theenumMembersemantic token instead of being colored as properties, whileClassName::$propertystill emitsproperty. Contributed by @calebdw. - PHP attributes use decorator semantic highlighting. Attribute class names in
#[...]now emit thedecoratorsemantic token instead ofclass, 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/showDocumentis 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\HttpClientandApp\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-typealias, 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@mixintargets before falling back to__callStatic(), so values likeDriver::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::classconstants for sibling subclasses no longer report false argument-type mismatches againstclass-string<static>. Contributed by @calebdw.class-string<static>parameters now diagnose provably invalid class strings. Passing an unrelated class to aclass-string<static>parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolvesstaticto 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.staticand$thisnow 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::, andparent::. Writing the class out pins it, soA::create()on a@return staticmethod resolves to exactlyA, and so donew A, a variable declaredA, and a::classstring.parent::create()binds to the calling class rather than the parent,@return selfstays 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 asstatic(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 enumcase(for exampleprotected 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 asmixed, so$x = $valueafter$x = nullparticipates in post-loop merge andis_nullearly-return narrowing instead of leaving a falsenulltype 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$foois&$x) is valid PHP and now defines the variable for later use, matching free functions likepreg_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 examplelockForUpdate()) and are reached via@mixinno longer dropBuilder<TModel>beforefirstOrFail()/first(), so the result types as the model instead ofModel|stdClass. Classes that use Laravel'sForwardsCallstrait 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 substituteTModelwith the model's fully-qualified name instead of its short name, souse App\Models\Channel as ChannelModelnext to anotherChannelimport still typesChannelModel::whereName(...)->firstOrFail()asApp\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 foundor 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 anarray_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 aresources/backoffice/viewsdirectory) no longer see validview()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@stackpreviously 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.$errorsand$__envare 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 thefunction/constmodifiers 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:$messageshorthand 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 likehref="mailto:x", a10:30in 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 theconfig/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 foundon$model->id. The primary key is synthesized for every Eloquent model, honouring$primaryKeyfor the column name and$keyTypefor the type (intby default,stringfor 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
$appendsor other Eloquent metadata arrays. LegacysetXAttributemutators 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
$thisas the concrete facade target.$thisinside callbacks such asRequest::macro('shouldReturnJson', function () { ... })andContext::macro(...)now resolves to the class behind the facade instead of the surrounding service provider. Contributed by @calebdw. self::andstatic::inside Laravel macro callbacks resolve to the macro target. In a closure passed to amacro()registration (LaravelMacroableor Carbon),self::andstatic::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 idiomself::this()->...no longer reports a false unknown-method diagnostic, and completion afterself::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 everyfind()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/@casewith a class-constant case value no longer reports a syntax error.@case (Some\Namespaced\Enum::VALUE)now translates to a validcasearm instead of silently corrupting the rest of the file.- Generated return types and
@returntags 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 tomixed(or, for multi-line array literals, to a coarserarray<mixed>) for anything beyond a simple literal,new, or a plain variable. - Calls to functions declared in another
namespaceblock of the same file resolve their return type. In a file that declares more than onenamespace, a call to a function from a later block used to leave the returned value untyped unless the function carried an@returndocblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type. @templatebindings resolve correctly when a call uses named arguments. A generic function or method called with named arguments (for exampleprocess(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, andcomposerindexing 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(), andparent::method()resolve differently depending on which class they appear in, and$var->method()resolves differently depending on what type$varholds at that call site. Two classes each declaring their ownself::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, andassert($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 asReader|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 ("Writerdoes not satisfyReader"). 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$xagainst its un-narrowed type, souseString($x ? $x : '')on astring|falsewas reported as a type error inside the branch written to prevent it. The then branch is now narrowed the same way anif ($x) { … }body already was, for the same guards: a bare truthy check,$x !== null,isset($x),!empty($x), and theT|falseidiom'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 toe($value), which takes a scalar, while__()/trans()/Lang::get()are declaredarray|stringbecause a translation key may name a whole group of strings. A literal key is resolved against the project'slang/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 tostring; a key that names a group, or that cannot be resolved, keeps the full union. - A
forloop's condition narrows its body the same way anifor awhile's does.for (; ($row = fgetcsv($handle)) !== false; )andfor (; $line !== false; $line = readLine())are the two shapes a read loop is written in when aforeachwon't do, and neither ruled anything out: the body saw the wholearray|falseorstring|falsethe 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 waywhile's is, and the inverse narrowing applies to the scope after the loop, so every guard form awhilealready understands (!== false,!== null,instanceof,isset, a@phpstan-assert-if-truepredicate) works the same way in afor. - A
@methodtag's own inline template parameter is no longer read as a class name.@method TVal get<TVal of mixed>(TVal $default)declaresTValinline, between the method name and its parameter list, and that declaration went unread: both uses ofTVal, 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@templatetag is, scoped to that one@methodtag so a different@methodtag 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 declaredstring|falsereported the call as stillstring|falseinside the branch that had just ruledfalseout, 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 likestring|falseresolves 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
falsecheck narrows itselsebranch too, not just the guard clause that returns.if ($value === false) { … } else { useString($value); }left$valueasstring|falsein theelse, and so did!empty($value), which rules outfalsealong withnullbut was only ever strippingnull. Both directions of the equivalentnullcheck already worked, so the gap was specific tofalse. 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 plainnullcheck. - 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
@returnkeyed on a value (($format is 0 ? int : list<string>)) only ever recognised a quoted string, so anintliteral 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 anintmethod 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' $keyfollowed by more tags below, had that first tag ignored for@paramand@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@returnfurther down the same docblock (which was read) could then be reported as incompatible with the body's widened value. ?->on anullsubject is no longer reported as a crash.echo $customer?->id;where$customerisnullwas 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 tonullwithout touching the property. The diagnostic now tells the two operators apart and only reports anullsubject 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()andsubstr_replace()return whatever shape their subject was, but their signatures can only name the flat union of both overloads, so every call was read asarray|stringno matter what it was handed. Passing the result of a replace on a plain string straight into astringparameter or returning it from astringfunction 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()'snullerror result survives for a string subject, where PHP really can return it, and is dropped for an array subject, where it cannot. json_encode()withJSON_THROW_ON_ERRORcan no longer befalse. The flag is how modern code asks for aJsonExceptioninstead of a silentfalse, and the declaredstring|falsereturn type has no way to say so, so every such call was still read as possiblyfalse: handing the result to astringparameter, 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 asJSON_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->mobileis astringwhatever 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@templatebound 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, sostr_replace('a', 'b', (string) $value)is astringand not the string-or-array union its signature also allows. - A parameter default written
self::SOME_CONSTresolves 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 throughself::,static::, orparent::, 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 callget(), found nothing there, and left the conditional return type undecided, so$container->get(Service::class)reportedService|object|nullinstead 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 writingSupport\Penwherever the class is wanted is a common alternative to oneuseper class, and PHPantom kept that spelling as a type of its own rather than resolving it toApp\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 thecallable(...)it was passed to. A::classwritten 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-thisresolves its class from the file that declares it. The tag names the class a callback's$thisis 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,$thissilently 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 returningBuilder<Author>, so a method declared: BelongsTo(the standard Laravel signature) got a falsetype_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,@methodvirtual methods (such aswithTrashedfromSoftDeletes), andwhere{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
floatreaching anintposition outsidedeclare(strict_types=1)is no longer reported.int / intresolves toint|float, which is correct, but a file with nostrict_typesdeclaration coerces the float half of that union on the way in rather than raising aTypeError, so$this->timeout = $max / 300against a typedint $timeoutproperty, andreturn $length / 86400;from a function declared: int, were both reported for a branch PHP itself accepts. Afloatargument, return value, or property assignment is now accepted the same waynumericandstringalready are outsidestrict_types. Underdeclare(strict_types=1)PHP really does refuse a float, but it also keepsint / intan 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 readsint|float, andis_float()still narrows it. This is the operator's own union and not unions at large, so a declaredint|floatstill has to fit anintwhole. int ** intcarries the same benevolentint|floatunion as division. PHP promotes exponentiation to afloaton overflow (2 ** 64) or a negative exponent (2 ** -1), a property of the operand values rather than their types, sotakes_int($base ** $exp)and$base **= $expunderdeclare(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 declaredint|floator 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
resultIdcounted 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 wayworkspace/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/phpstanthe project doesn't actually depend on. Aphpstanbinary from a transitive dependency of something else, was proxied as though the project itself used PHPStan. Auto-detection undervendor/binnow only fires whencomposer.jsonrequiresphpstan/phpstandirectly, inrequireorrequire-dev. Aphpstanfound on$PATHis unaffected, since installing it globally is a deliberate choice, and an explicitcommandin.phpantom.tomlstill overrides detection entirely, which is how a manually managed install (a versioned.pharoutside 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.tomlat 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 onmago lint, an[analyzer]table turns onmago analyze, and a file that carries neither turns on neither.lintandanalyzeunder[mago]in.phpantom.tomloverride the detection in either direction, so a project that runs a checker without configuring it can still ask for the reports. mago analyzeno 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 analyzeis therefore proxied only when themago.tomlwires one up, either an enabled[extension-hosts.*]entry or a namespaced plugin such asplugins = ["acme/laravel"]. Mago's own plugins (stdlib,psl,flow-php,psr-container) do not count, since none of them carries that knowledge.mago lintkeeps running, as its linter does have a Laravel integration, andanalyzeunder[mago]still forces the issue either way.- Mago auto-detection no longer picks up a
vendor/bin/magothe 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 undervendor/binnow only fires whencomposer.jsonrequirescarthage-software/magodirectly, and a global install or an explicitcommandis unaffected. non-falsy-stringis accepted wherenon-empty-stringis expected.non-falsy-string(and its Psalm synonymtruthy-string) excludes both""and"0", so it is strictly narrower thannon-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-stringandtruthy-stringare synonyms of each other, and both are subtypes ofnon-empty-string.- A method declared
@return mixedreads its real return type off its body.mixedis 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)stayedmixedeven though its body plainly returned aWidget. 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, andmixedis still what remains once inference truly has nothing better to offer. A bare@templateparameter without a bound already erases tomixedfor 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 thanmixedwas. - 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/phpstanbut not aphpstanon$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 aphpstan.neonstill 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 directphpstan/phpstandependency is honoured again. - A
phpstan.dist.neoncertifies 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 whethervendor/bin/phpstanmay 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.tomlautocomplete knows the Magolintandanalyzekeys. Both keys work and are documented, butconfig-schema.jsonstill listed onlycommandand 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.tomlwas documented, but the config is read from~/.config/phpantom_lsp/.phpantom.tomlon 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
getorsethook 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, asethook's implicit$valueis defined without being declared, and theisset(),compact(), andextract()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,
@mixinmethods, 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 ownLaravel\Folio\name()declaration was invisible toroute(): a route it named was reported as unknown, and completion, hover, and go-to-definition insideroute()never found it. The mounted page directory is now discovered from the framework's ownwithRouting(pages: ...)default and from explicitFolio::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/@mixinsynthesized 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/staticreturns 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 theirreturnstatements. Incompatible return values are flagged as errors. Void functions returning a value and barereturn;in non-void functions are also flagged. Generators (functions usingyield) are skipped. Uses the same conservativeis_type_compatiblepolicy as argument type checking to avoid false positives. Contributed by @calebdw. - Property type assignment diagnostics (
type_mismatch_property). Assignments to typed properties ($this->prop = exprandself::$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 andmixedproperties 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'smock(Foo::class)and Laravel's$this->mock(Foo::class)therefore resolve toFoo&MockInterface, so their members complete and assigning the result to aFoo-typed property or returning it from aFoo&MockInterfacemethod 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
useimports 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 fromcomposer.jsonandinstalled.jsonduring indexing. Contributed by @calebdw. analyzeandfixwork 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-rootis not silently analysed as a bare tree.updatecommand. A newphpantom_lsp updatesubcommand 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_mapinfers the output element type from its callback. The result ofarray_mapnow reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars likestringorint, soarray_map(fn(Item $item): string => $item->id, $items)produceslist<string>rather thanlist<Item>. When the callback has no return type hint, the type is inferred from its body expression, soarray_map(fn($item) => $item->id, $items)over alist<Item>also produceslist<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 asRoute::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 withClass::classconstants,$this, and typed variables. (thanks @calebdw) - Convert arrow function to closure. A new
refactor.rewritecode action converts arrow functions to anonymous closures (fn($x) => $x * 2tofunction($x) { return $x * 2; }). Variables from the outer scope are automatically captured via ause()clause. Preservesstaticand return type hints. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191. @phpstan-sealedtag support. The@phpstan-sealed FooClass|BarClassPHPDoc 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. Whencomposer.jsonorcomposer.lockchanges (e.g. aftercomposer 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 likeparse_url,stat,pathinfo,gc_status,getimagesize, andsession_get_cookie_params.- Convert to arrow function. A new
refactor.rewritecode action converts single-expression closures to arrow functions (function($x) { return $x * 2; }tofn($x) => $x * 2). The action is only offered when the conversion is safe: single return statement, no by-referenceusecaptures, novoid/neverreturn type, and PHP >= 7.4. - Convert switch to match. A new
refactor.rewritecode action convertsswitchstatements tomatchexpressions when all arms are single-expression returns or assignments to the same variable. Handles fall-through cases (merged with commas), trailingbreakremoval, andthrowarms. Requires PHP >= 8.0. - Extract interface. A new
refactor.extractcode action generates an interface from a concrete class. All public method signatures (excluding the constructor) are extracted into a new{ClassName}Interface.phpfile in the same directory, and the class is updated withimplements {ClassName}Interface. Class-level and method-level@templatetags are preserved when referenced by extracted methods. @templateon@methodtags. Virtual methods declared via@methodPHPDoc 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(), andnewModelQuery()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,@propertytags, timestamps, etc.). - Authenticated user resolves to the configured model.
$request->user(),auth()->user(), andAuth::user()now resolve to the Eloquent model declared inconfig/auth.phpinstead of only the bareAuthenticatablecontract, so completion, hover, and member access work on the concrete model ($request->user()->email). Naming a guard selects that guard's model, soauth('admin')->user(),Auth::guard('admin')->user(), and$request->user('admin')resolve to the model configured for theadminguard rather than the default one. The config is read statically: only the literal default ofenv('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, ornew Foo(), and also recognizes typed variable registrations likeBuilder $queryfollowed by$query->macro(...), including inside callbacks such asfunction (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')andapp('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\Appand\DBresolve 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 ownRequestin the current namespace) still wins, since the alias table is only consulted after namespace-aware resolution misses. model-property<T>pseudo-type recognition. The Larastanmodel-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 tocompact('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
usestatement 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'toWorkItemController::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 fromvendor/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'signoreErrors. 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-ignorecomments scattered through the codebase. - Built-in formatter respects
mago.toml. When formatting falls back to the embedded formatter, amago.tomlat 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
$paramin 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@paramtag and function body were updated but the@returnconditional was left stale. Contributed by @calebdw. @param-closure-thissupport in hover, go-to-definition, and go-to-type-definition. Hovering on$thisinside a closure whose enclosing call site declares@param-closure-thisnow shows the overridden type instead of the lexically enclosing class. Go-to-definition and go-to-type-definition on$thislikewise 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/modularmodules) are now discovered fromvendor/composer/installed.jsonand 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 rootcomposer.json's own PSR-4 directories. Only packages whose files live outsidevendor/(symlinked in from a module directory such asapp-modules/) count as project source; a path repository that resolves back insidevendor/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
analyzerun (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/elsewrites 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 theDatefacade orDateFactory, sonow(),today(), Date facade calls, and DateFactory calls resolve to the actual generated type (for exampleCarbon\CarbonImmutable) rather than the framework's broadCarbonInterfacedeclaration or defaultIlluminate\Support\Carbon. Variable inference, return diagnostics, and inferred hover returns preserve nullable results such asCarbonImmutable|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 theDate::use()call in a provider updates the resolution during the same editing session, and aDate::use()call in a file that is not a registered provider never overrides the project's real configuration. - A conditional
@returntype 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'sData::collect([...]), which yieldsarray<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'sarraymember). Member access, hover, and the return- and property-type mismatch diagnostics all see the precise type, eliminating false "incompatible with declared typearray<Foo>" reports. new ReflectionClass($class)resolves instances to the reflected type. When the argument is aclass-string<T>,newInstance()andnewInstanceArgs()now resolve to the object typeT(nullable fornewInstanceArgs) 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 mixesobjectwith a scalar (such as theobject|stringhint 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 wholeanalyzerun. 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$declarationsis a barearray), a followingassertInstanceOf(Wanted::class, $type)now narrows$typeto 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. assertInstanceOfnarrows when the expected class is held in a variable. Passing a variable that holds a::classvalue as the first argument ($cls = Wanted::class; assertInstanceOf($cls, $subject)) now narrows the subject the same way the inlinedWanted::classliteral does, including when the variable is assigned inside a loop or other braced block or list-destructured out of the array aforeachiterates ([$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 anis_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 aclass-string<Foo>parameter. Previously the narrowing was silently dropped when the subject was an array-index expression, leaving the element's type unresolved. - A
forloop's init-clause variable resolves in the condition and update clauses. A variable assigned in the init clause of aforloop (for ($p = $e->getPrevious(); $p; $p = $p->getPrevious())) now has its type available in the condition and update expressions on the sameforline, 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): TReturnwith@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 toDecimal, 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$eas 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>|falsereturn keeps its element type after afalsecheck. A function typedarray<int, User>|false(nativearray|falserefined by a docblock) now retains the array's element type, so afterif (!is_array($result)) return;orif ($result === false) return;the surviving array iterates to the declared element instead of losing it. Previously only the|nullvariant worked; the|falseunion 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 unqualifiedresponse()->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@returnis 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'sCollection::chunk(), where iterating the result (foreach ($items->chunk(500) as $batch)) gave$batchno 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
mixedstay usable. A call whose conditional@returnresolves tomixed(such as Laravel'ssession($key)with($key is string ? mixed : null)) now gives the value the typemixedinstead of leaving it untyped. The value can then be narrowed as usual, so a lateris_string()/instanceofguard 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 $thiskeep the using class. A trait method that returns$thiswithout 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 likeifguards, 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 variablerefactor 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-extendsgives$thisthe base class's members inside a trait. A trait annotated@phpstan-require-extends Basecan now useBase's methods, properties, and constants on$thiswhen 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
@returntype such as(Foo&object{pivot: Bar})|nullnow resolves correctly. Previously any@returnstarting 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 aclass-string<T>argument, the element is inferred from the argument at the call site. Enumcases()results resolve too:Status::cases()[0]->valueknows the element is the enum, sincecases()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'), orisset($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 inifstatements 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/assertFalseprove their wrapped condition. A check wrapped inassertTrue(...)orassertFalse(...)(any method carrying@phpstan-assert true/false $condition, as PHPUnit's do) now narrows exactly like the equivalentifguard, because the assertion re-exports its inner condition.assertTrue(property_exists($model, 'value'))proves the property for the rest of the scope, andassertFalse($x instanceof Foo)excludesFoofrom$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, asassertIsString,assertIsObject,assertIsArray, and the rest carry) now narrows the value like the matchingis_*()guard, and theassertIsNot*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 typedclass-string<Foo>(via@varor@param) that then passes through aclass_exists($var)guard clause (if (!class_exists($var)) { throw; }) now keeps its<Foo>type argument instead of being widened to a bareclass-string. As a resultnew $var()still resolves toFoo, so member access on the resulting object continues to work. - Each
if/elseifbranch narrows a property path to its own type. A property or array-element path ($args[0]->value) narrowed byinstanceofin one branch no longer leaks that type into a laterelseifbranch 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. instanceofnarrows a parameter inside an arrow-function body. Infn($x) => $x instanceof Foo && $x->method(), the parameter$xnarrowed by the first&&conjunct is now visible to the member access in a later conjunct, so completion, hover, and member access resolve againstFooinstead 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
@templateparameter constrained byarray<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_mapandarray_filtertype their callback parameter. A closure passed toarray_maporarray_filternow 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-stringdefault resolves when called with no arguments. A container-style accessor declared@template T of object,@param class-string<T> $name,@return Twith aFoo::classparameter default now bindsTfrom that default when called with no arguments, so$app = app()resolves to the default class exactly asapp(Foo::class)binds toFoo. Member access on the result completes, navigates, and type-checks instead of reporting the type as unresolvable. - Class-string unions carry through a
foreachover an array-literal variable. Iterating a variable assigned a list of::classconstants ($repos = [A::class, B::class]; foreach ($repos as $r)) now resolves each element to its class, so a call likeapp()->make($r)binds itsclass-string<T>template to the union and the chained call resolves. foreachover 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'scurrent()method, so members like$file->isFile()and$file->getRealPath()complete, navigate, and type-check.- An inline
@varbefore aforeachrefines a broad iterable variable. A/** @var iterable<Foo> $items */placed just beforeforeach ($items as $item)now types the loop variable even when$itemsalready carried a broad type such asmixed(common formixedclosure or function parameters) or a barearray. 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@varnaming 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 tocompact()(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->orderproductsfor anorderProducts()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. $thisinside an anonymous class resolves to that class. Members accessed on$thiswithin 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 thenew 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 toarray_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 asApp\Input\Iteratorlost to the global SPL\Iterator, so every member on the instance was reported as unknown. An explicituseimport 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$subjectinStr::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()andempty()guard their own access. Checkingisset($obj->prop)orempty($obj->prop)no longer reports the property as unknown or unresolved, even when the subject's type is a union that includesstdClass. Neither construct ever errors at runtime when the member doesn't exist, so flagging them was always a false positive.- A method returning
objector?objectallows member access on its result. Accessing a property or method on the result of a call whose return type isobject(or the nullable?object) is now treated as the "any object" escape hatch it is, so$repo->all()->projectsno longer reports the subject type as unresolvable. Nullability no longer discards theobjecttype. - Assigning an object to a property tracks that property's type. After
$settings->cache = new stdClass(), reading$settings->cacheresolves tostdClass, so a further access like$settings->cache->ttlno longer reports the subject as unresolvable. This makes nested object graphs built up field by field (a commonstdClassconfiguration pattern) resolve for hover, completion, and diagnostics. Assigningnullto 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 onnull. - A type guard trusts the runtime check over an incomplete static type. When
is_object($x)(oris_string(),is_array(), and similar checks) succeeds but$x's inferred type didn't account for that possibility (for example aforeachelement 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)andclass_exists($value)narrow a string toclass-string. With theallow_stringargument,is_a()accepts a class-name string as well as an object, and now narrows accordingly toclass-string<Class>rather than an object instance.class_exists(),interface_exists(),enum_exists(), andtrait_exists()narrow to the genericclass-string. This also narrows through guard clauses (if (!is_a(...)) { throw ...; }).is_numeric()on a string narrows tonumeric-string, not a bare number. The narrowed type previously dropped thestringpossibility entirely, so passing the checked value on to astringparameter reported a spurious mismatch.- A bare truthy check strips
nullfrom the checked variable.if ($value) { ... }now removesnull(andfalse) from a nullable type inside the branch, matching the existing behavior ofisset()and!== nullchecks. - Type-guard narrowing survives compound conditions and non-variable subjects.
instanceofandassertnarrowing 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-asserton 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
assertInstanceOfwith 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 toobjectintersected with the prior type, droppingnullwhile 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-keysatisfies anint|stringparameter. Passing a value typedarray-keyto a parameter expectingint|stringno 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 aclass-string<T>template parameter. Passing a value typedclass-string<A|B>to a generic parameter typedclass-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 itsclass-stringwrapper. - 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 classApp\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#methoddocblock references resolve the class. Legacy phpDocumentor fragment syntax (@see ASTNode#getMetadataSize) previously looked up the wholeClass#methodstring as a single class name and reported it as unknown. The class and member are now split and validated independently, the same as theClass::methodform.@mixinof an Eloquent model exposes the model's synthesized members. A plain class annotated@mixin SomeModelnow 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->linkCampaignor$cart->itemsthrough 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.@mixinof a template parameter resolves through its bound. A class annotated@mixin TwhereTis a@template T of SomeTypeparameter now exposesSomeType'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 byCallableNode<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) leftTunresolved when accessed inside the method itself, so calls likeend($items)->method()reported the member as unknown. Member access, hover, and completion on the parameter now resolve through the declared bound. $thisnarrowed byassert()resolves inside closures with no enclosing class. In a top-level test closure (such as a Pestit(...)body),assert($this instanceof TestCase)now makes$thisresolve 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.instanceofnarrowing of$thisto 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'sCache::remember($key, $ttl, fn() => new Order())resolves toOrder(as dorememberForever,sear,flexible, andwithoutOverlapping), 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(), orcursorPaginate()now resolves the loop variable to the model, soforeach (User::paginate() as $user)gives$userthe concrete model type and member access on it resolves. Storage::fake()resolves to the concrete filesystem adapter.Storage::fake()andStorage::persistentFake()now resolve to theFilesystemAdapterthey 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\Iteratoror\Traversablekept 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 toFooand$pair[1]toBar. 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::classresolves to a class-string. The magic::classconstant now resolves toclass-string<Class>instead of a plainstring, so the class identity survives through assignments, array elements, andclass-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::classexpression keeps the value aclass-stringrather than collapsing tostring. Passing the result to aclass-string<object>parameter no longer reports a spurious type mismatch. - Method calls handled by
__call/__callStaticare 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(), andspy()now resolve to the intersection ofFooand the Mockery mock contract, matchingMockery::mock(). The mock therefore satisfies a parameter or array element typedFoo(sonew Result([$this->mock(Rule::class)])against anarray<Rule>no longer reports a spurious mismatch), still passes to a method expecting the mocked class, and keeps resolving mock-expectation chains such asshouldReceive()->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 toarray_walk) is now recognized as used, since the write propagates back to the outer scope through the reference. - Passing
nullto an implicitly-nullable parameter is no longer flagged. A parameter keeps its ability to acceptnullin two cases the type checker previously lost: when a docblock@paramnarrows a nullable native hint (a@param Foo[]over a native?arraystill acceptsnull), and when the parameter has a literalnulldefault (Type $x = null, the pre-8.4 implicit-nullable form). Calls passingnullto 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)whereINVALIDis an untypedintconstant now binds the template parameter tointinstead 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 nonsensicalclass-string<string>. A bareclass-stringvalue is likewise accepted, resolving the parameter toclass-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 Boundparameter typedclass-string<T>now bindsTto 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
::classargument 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 $xwithSomeClass::class), it now infers the argument's actualclass-string<SomeClass>type instead of the bare class name, so the parameter is no longer compared asSomeClassagainst the veryclass-string<SomeClass>argument that bound it. This clears false positives on the commonMockery::type(SomeClass::class)pattern. Parameters that accept either a class name or an instance via aclass-string<T>|Tunion, including the variadic array shape used byMockery::mock(SomeClass::class), still bindTto 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$stmtholds 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
Iteratordirectly now resolves the loop variable's type. Previously onlyIteratorAggregateand classes with an explicit generic annotation (@implements Iterator<Key, Value>) resolved aforeachloop variable's type; a class implementingIteratoritself fell through to unresolved. This most commonly affectedSimpleXMLElement:foreach ($xml->children() as $child)now resolves$childtoSimpleXMLElement, 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 falseunresolved_member_access. - Assignments written inside a condition are now tracked. A variable assigned in an
iforwhilecondition is recognized as a definition, including the bare negated guardif (!$item = find()) { return; }and the call-wrapped formwhile (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, aninstanceof(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 idiomif (!$x instanceof Foo || !$x->method()) { continue; }resolves$x->method()againstFooinstead 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-assertand@psalm-assertannotations 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-php83that 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$thisaccesses on unknown members. Classes with__call,__callStatic, or__getcatch-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.
namespaceandusedeclarations 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_argumentdiagnostics. Functions with multiple signatures likestrtr(string, string, string)andstrtr(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. Previouslyiterator_to_array($iter)where$iterwasIterator<Foo>resolved toIterator<Foo>instead ofarray<Foo>, producing falsetype_mismatch_argumentdiagnostics when passed to array-typed parameters. Both key-value (Iterator<int, Foo>toarray<int, Foo>) and value-only (Iterator<Foo>tolist<Foo>) generic params are preserved. Contributed by @calebdw.- Reassigning a variable from its own array offset now updates the type.
$value = $value[0]after$valueheldlist<string>|falsenow correctly narrows$valuetostring. Previously the scalar element type was skipped during array-access resolution, leaving the variable with its old array type and producing falsetype_mismatch_argumentdiagnostics. Contributed by @calebdw. @phpstan-type/@psalm-typealiases no longer trigger falsetype_mismatch_argumentdiagnostics. 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
ifbranch no longer leaks into laterelseifconditions or theelsebranch. A variable changed in one branch is resolved against its pre-branch type in a followingelseifcondition,elseifbody, orelsebody, 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 laterelseif/elsebranches and in the scope after the block, matching the brace syntax. For example, afterif ($x === null): $x = new Foo(); endif;written with colons,$xis now known to be non-null pastendif;. - Ternary conditions narrow property and method-call subjects. An
instanceofcheck in a ternary condition now narrows a property or method-call subject inside the branch, so$this->node instanceof Artifact ? $this->node->getCompilationUnit() : nullresolves 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 onnull). Previously only plain variable subjects narrowed in ternaries. int<0,max>no longer triggers a falsetype_mismatch_argumentagainstnon-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)(typedFoo&MockInterface&LegacyMockInterface) is passed to a parameter typedFoo&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 thenumberpseudo-type. Members on such a value resolve, and passing it to a parameter of the same class type no longer raises a falsetype_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 legacyresource|false. Passing the handle on tofinfo_file,imap_close, and the like no longer reports a spurioustype_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->datainside a stream filter'sfilter()method no longer reports an unresolved-member warning on older configured PHP versions.- External formatters no longer corrupt the connection. Running
php-cs-fixeror 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] timeoutin.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 containingyieldexpressions is passed to a method with a union param type likeiterable<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 fixesLazyCollection::make(function() { yield (string) $x; })resolving asLazyCollection<Closure, Closure>instead ofLazyCollection<int, string>. Contributed by @calebdw. - Foreach key type resolves through a generic
IteratorAggregate. When iterating a class that implementsIteratorAggregate<non-empty-string, SplFileInfo>, the loop's key variable previously fell back toint|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()/constvalue, 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>andint<min..max>constraints when the value falls within the declared bounds. This fixes false positives likeusleep(10_000)againstint<0, max>and Laravel-style calls such asrepeatEvery(1)againstint<1, 59>. Contributed by @calebdw. +=on arrays now infersarrayinstead ofint|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)tobar(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. AtextDocument/didSavehandler 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/refreshin pull mode so editors see results without requiring adidChangeevent. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196. @phpstan-ignorewith 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-ignoreand*/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'tonumeric-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 fromstringtoarray<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. mixedno longer behaves like a scalar in array access and member diagnostics. Accessing a key on anarray<string, mixed>parameter (e.g.$body['key']) was incorrectly returning an empty type becausemixedwas treated like a scalar and skipped by the element-type extractor. This caused ternary expressions liketrue ? $body['key'] : nullto resolve asnullinstead ofmixed|null, producing false type-mismatch diagnostics. The same misclassification could also surface unverifiable-member warnings on values typed asmixed. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/210.@see self::member()references in class docblocks now navigate correctly. Docblock@seetags already supportedClassName::member()references, butself::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 withself::...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
useimports 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 groupedusedeclaration could fail because the grouped item was recorded without its namespace prefix. Imported names inside groupedusedeclarations 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 missingnulldefaults. Method template binding now avoids inferring template parameters from omittednulldefaults except in the few cases where defaults are actually meaningful for template resolution. This fixes falsetype_mismatch_argumentdiagnostics on calls likewhen($request->integer(...), fn ($q, $id) => ...), where the callback parameter type was being collapsed tonullinstead 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 thedeclare(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/prepareTypeHierarchycapability was registered without registration options, so some clients (notably Zed) did not reliably expose the Type Hierarchy action. The dynamic registration now carries properTypeHierarchyRegistrationOptionswith 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
returnwhose 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'sapp(),session(), androute()) 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.phpfile that sits beside an autoloaded helper file but is pulled in by the framework's own bootstrap rather than Composer'sfilesautoload, so it never appears in the autoload manifest. CakePHP is the canonical case: helpers like__(),h(), andenv()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/elseversion guard (the DoctrineServiceEntityRepositorypattern, where a base class is defined differently per ORM version) was previously discovered by name only, so its parent and@extendsgenerics 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. @varannotations 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()andfetchAll()now resolve to the type produced by the fetch-mode constant passed to them, sofetch(PDO::FETCH_OBJ)is an object,fetch(PDO::FETCH_ASSOC)is an associative array, and iteratingfetchAll(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 tomixed, so$x = $arr['key'] ?? 5no longer produces spurious type errors.foreachelement 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. selfreferences inside class-level attributes resolve. Aself::,static::, orparent::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.@methodtags override inherited methods of the same name. A@methodannotation 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 genericobject, 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 toFoo(or the union of its existing non-null type and the assigned value), so property and method access on$xis no longer reported as unresolvable.- Generics with fewer arguments than parameters.
@extends Collection<User>againstCollection<TKey, TValue>now bindsUserto 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'sfind()returnsEntity|nullinstead of the bareobject|null. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/152. - Conditional
is nullreturn types resolve consistently regardless of how the call site is parsed, and an explicitly passednullnow selects the null branch. - Go-to-definition, rename, and highlight accuracy. References in
@seetags to qualified names likeApp\Foo::bar()now land on the correct location, and renaming a property selects the whole$nameinstead of$nam. @phpstan-require-extendsand@phpstan-require-implementsnavigation. 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 withuse ($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
__constructdeclaration now reports thenew ClassName(...)instantiations,#[ClassName(...)]attribute usages, and explicit delegation calls written asparent::__construct(),self::__construct(), orClass::__construct(), including for subclasses that inherit the constructor (and excluding subclasses that override it). Attribute classes that are never written asneware 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-ignorequickfix 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;anduse 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
classkeyword 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\nterminators the edits drifted one byte per preceding line and could corrupt the file. Offsets now account for the real terminator. - Malformed
@methodtags no longer crash requests. A docblock with a degenerate@methodsignature (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.
@mixinwith union types.@mixin Foo|Barnow correctly exposes members from all classes in the union. Previously only single-class mixins were recognized.throw newandcatchcompletion behave likenew. Interfaces, abstract classes, traits, and enums are filtered out ofthrow newcompletion, which now offers only Throwable descendants, matchingnew. Completion insidecatch()and@throwsnow 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 callsget_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
1passed to apositive-intornon-negative-intparameter no longer produces a falsetype_mismatch_argument, matching the existing behaviour forint<min,max>ranges. Passing a literal that genuinely violates the refinement (e.g.0topositive-int, or a negative literal tonon-negative-int) is now correctly flagged.non-zero-intandcallable-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
ArrayAccessresolves throughoffsetGet.$obj[$key]on a class implementingArrayAccessnow resolves to the value type declared in a generic annotation (@implements ArrayAccess<TKey, TValue>), falling back tooffsetGet()'s own declared return type when no annotation is present, mirroring howforeachalready fell back toIterator::current(). This also fixes a class's own@templateparameter resolving to its declared bound instead of leaking through as an unrelated type name when referenced directly in that same class's@implements/@extendsannotations. - Reassigning a variable using its own previous value resolves the reference correctly. In
$x = f(fn() => ..., $x), the$xread 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-typeand@phpstan-import-typealiases are recognized rather than treated as class names, and@param,@return, and@vartypes 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 returningself, but Mockery actually returns a verification director object that exposeswith(),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@varor elsewhere) now resolves to the global\Redisclass regardless of ause 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.phpfiles. 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.phpfiles 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 ausestatement 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, andmatch(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:
newon abstract classes, interfaces, traits, or enums;extendson a final class, interface, or trait;implementswith a non-interface; traitusewith a non-trait;instanceofwith a trait;catchwith 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/pintinrequire-devautomatically use Pint for formatting via stdin. Configurable under[formatting]in.phpantom.tomlwithpint = "path"orpint = ""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
@returndocblock now have their return type inferred fromreturnstatements, 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.
@mixintags referencing a template parameter now resolve through the template bound.new $var()where$varisclass-string<T>resolves toT. SPL collection classes now carry@templateparameters 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 afteruse, 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(orself::$propin 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 + int→int,int + float→float,int / int→int|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.
globalkeyword variable resolution. Variables imported withglobal $varnow resolve to their top-level type, enabling completion, hover, and go-to-definition.array_reduce,array_sum, andarray_productreturn type inference.array_reduce()resolves to the type of its initial value argument.array_sum()andarray_product()resolve toint|float.- Machine-readable CLI output. Both
analyzeandfixaccept a--formatflag withtable,github, andjsonoptions. WhenGITHUB_ACTIONSis set, table output automatically includes GitHub annotations. - Magic property diagnostics. New
report-magic-propertiesoption under[diagnostics]in.phpantom.toml. When enabled, classes with__getthat also have virtual properties (from@propertydocblock tags, Laravel Eloquent column inference, or other providers) will flag unknown property access instead of silently allowing it. - Inline diagnostic suppression.
// @phpantom-ignore codeon the same line or the line above suppresses the specified diagnostic. Multiple codes can be comma-separated. A bare// @phpantom-ignoresuppresses all diagnostics on the target line. - Find references and rename for PHPDoc virtual members.
@property,@property-read,@property-write, and@methoddeclarations 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|nullfrom->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_casenoun-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),
@vardocblock variable names are included, andunset()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 newcompletion missing vendor classes. Classes whose Throwable ancestry could not be immediately verified (e.g. vendor classes not yet parsed) were silently excluded fromthrow newandcatchcompletion, 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
@removedtags (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, andparentkeywords. Renaming a class no longer replaces occurrences ofself::,static::, orparent::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 functioninsertion. Accepting a function completion no longer inserts ause functionstatement 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 viaglobalare no longer incorrectly flagged. - False-positive type mismatch diagnostics. Bare
arrayreturn values passed to typed array parameters, properties narrowed viainstanceof, 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
@varcompletion. Variables typed only via a standalone/** @var Type $var */docblock now resolve for member completion and go-to-definition. @vardocblocks with additional tags. Extra tags like@psalm-suppressin the same docblock no longer corrupt the type string.- Foreach
@varannotations for key and value variables. Multi-line docblocks with multiple@vartags before aforeachnow correctly override both key and value types. - Foreach element type from untyped arrays. Variables in a
foreachover barearraynow resolve tomixedinstead 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
::classliteral arrays resolves static access.$className::CONSTand$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-implementson stub-loaded interfaces now correctly propagates substituted return types to child methods. - Generic method return types from
@varannotations. 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
@templatewithkey-ofbound. 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. __getmagic method template resolution. Property access on a class whose__getuseskey-of<T>bounds now infers the concrete type from the property name.- Magic
__getproperty access. Accessing undefined properties on objects with a__getmethod now resolves to the method's declared return type. - Magic
__callmethod return type. Calling undefined methods on objects with a__callmethod now resolves to__call's declared return type. - SoapClient arbitrary methods. Calling any method on
SoapClientno longer produces false-positive "unknown member" diagnostics. - Literal
true/falsepreserved in template inference. Passingtrueorfalseto a generic constructor now keeps the precise type instead of widening tobool. @psalm-methodoverrides@method. The vendor-prefixed tag now takes priority when both are present.@psalm-param/@phpstan-parampriority over@param.@phpstan-paramtakes precedence over@psalm-param, which takes precedence over@param, matching PHPStan and Psalm behaviour.@psalm-if-this-istemplate inference. Method-level template parameters are now inferred by matching the receiver's concrete type against the annotation's type pattern.self::classandstatic::classin template arguments. Passing these to aclass-string<T>parameter now correctly resolves T to the enclosing class.staticreturn type through first-class callables.self::method(...)()and similar patterns now preservestaticin 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/statictype resolution. Properties with@var self|nullorstaticnow resolve to the owning class name in hover. - Trait
selfreturn type resolution through inheritance. Trait methods with return typeselfnow resolve to the declaring class, not the calling subclass. - Conditional return type resolution for scalar arguments.
$param is stringconditions in@returnannotations now resolve correctly for literal values. - SPL iterator generic type propagation. Decorator iterators like
CachingIteratorandLimitIteratornow propagate the wrapped iterator's generic type parameters. ArrayIteratorconstructor generic inference.new ArrayIterator($typedArray)now infers key and value types from the array argument.range()return type inference.range()now returnslist<string>for string arguments andlist<int|float>otherwise, instead of barearray.(object)cast type inference. Casting now resolves to an object shape matching the operand's structure instead of barestdClass.- ArrayAccess array-access assignment.
$obj[$key] = $valonArrayAccessobjects no longer overwrites the variable's generic type with an array type. - Static method calls on class-string unions.
$variable::method()where$variableholds 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
filesautoload 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
$datesandwhere{Property}go-to-definition. Go-to-definition now works for properties backed by the$datesarray and dynamicwhere{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 toRedirectResponseas 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
analyzecommand 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.
analyzeandfixcommands run at consistent speed regardless of invocation style.- Type narrowing. Comprehensive fixes:
is_*()guards correctly narrow multi-member unions;instanceofonmixedorobjectnarrows to the checked type;=== nulland== nullnarrow correctly;assert()narrowing persists through subsequent branches;isset()/empty()stripnullfrom nullable types; property access expressions are narrowed through conditionals; array shape keys are narrowed through guard clauses; OR'dinstanceofchecks 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
@extendschains. 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
@mixinnow resolve through the mixin.@methodand@propertytags on mixin classes are propagated to the consumer.$thisreturn types on mixin methods resolve to the consumer class. @methodtag resolution. Colon return type syntax, parenthesised return types, and the ambiguous single-staticpattern are now parsed correctly. Template parameters in@methodreturn types are substituted through@extendsand@implementsannotations.- 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 numericpseudo-type. Functions annotated with@return numericnow resolve correctly instead of falling back tostring.parent::__construct()with@extendsgenerics. No longer produces false-positive type errors for substituted parameter types.- Array access on bare
arrayandmixedtypes. Accessing a key on plainarraynow resolves tomixedinstead 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
->valueresolves to the specific backing type.@implementsgenerics on enums are resolved correctly. - Class constants. Inherited constants accessed via
self::CONSTorChildClass::CONSTresolve through multi-level inheritance. - Hover / type display.
T[]displays asarray<T>,mixed[]asarray. PHPDoc type aliases are normalized. Methods returningparentresolve 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, andnew 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.
$thisno 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
ifblocks, 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
@paramannotations no longer leak across sibling methods or closures. class-string<T>parameter completion. Parameters typed asclass-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.
\Closureis now recognised as a subtype ofcallable. - Union-typed method calls no longer lose resolution on second occurrence.
- Fluent method chains in namespaced classes. Methods returning
staticorselfresolve 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
catchclauses 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
filesautoloading. - 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-vartag 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/@vartags, remove unused return type union members, fix unsafenew static()(add@phpstan-consistent-constructor,finalclass, orfinalconstructor), add or remove#[Override], add#[\ReturnTypeWillChange], fix void return mismatches, add inferred iterable return types, remove unreachable statements, remove always-trueassert()calls, fix overriding member visibility, fix vendor-prefixed class names, and simplify ternary expressions to??or?->. All quickfixes eagerly clear their diagnostic on apply. fixCLI subcommand.phpantom_lsp fixapplies automated code fixes across a project. Specify rules with--rule(multiple allowed) or omit to run all preferred fixers.--dry-runreports what would change without writing files. The first shipped rule,unused_import, removes unusedusestatements 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.
returnonly inside functions,breakonly inside loops, member keywords inside class bodies, enum backing types afterenum 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 asCarbonwith support for$timestamps = falseand custom column constants. Legacy$datesarrays produce typed virtual properties.$appendsentries produce virtual properties.where{PropertyName}()dynamic methods are synthesized from all known columns (including@propertyannotations) on both the model and the Builder.whereHas/whereDoesntHaveclosure parameters resolve toBuilder<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(), andis_callable()narrow union types insideif/else/elseifbodies 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
foreachiteration, 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
@returnor@paramdocblock, 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, andnew 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
@paramdocblock 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 $thisnarrowing. Instance methods annotated with@phpstan-assert-if-trueor@phpstan-assert-if-falsetargeting$thisnow 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
namespacesuggests 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
@vardocblock for untyped closure parameters. When a closure parameter lacks a type hint and no assignment follows, a@varblock above the usage is now picked up as the variable's type. --stdioCLI flag. Accepted (and ignored) for compatibility with LSP client wrappers that pass--stdioby default. Contributed by @markkimsal in https://github.com/PHPantom-dev/phpantom_lsp/pull/67.--tcpCLI flag.phpantom_lsp --tcp 9257starts 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 Builderwith@param T $querynow resolves$queryto 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@varannotations. 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&$paramparameters 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-ignoreis 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
@returnfrom the function body. Typing/**above a function that returnsarraynow 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 ofnullare 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
<?phpopen tag. Typing<?phpand pressing enter no longer applies a spurious function suggestion likephp_ini_loaded_file(). - Case-insensitive
parenthandling in chained static calls.resolve_lhs_to_classnow handlesparent::method(...)in chained callable expressions and uses case-insensitive matching forself/staticin 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|intwithis_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, andClassName::$propno 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, andparentresolution.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->propand 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
@paramtype needs enrichment. Types that are semantically equivalent but formatted differently (e.g.\App\UservsApp\User) no longer trigger spurious updates. Body-based@returnenrichment now correctly detects when an existing@returntag already has type structure, instead of always proposing a replacement. @phpstan-assertand@psalm-asserttags with generic types. Assertions like@phpstan-assert Collection<int, User> $paramnow parse the full generic type instead of truncating at the first space inside angle brackets.parent::method()resolution in inline arguments. Passingparent::method()as an argument to a function now resolves the return type correctly, matching the existing handling forself::andstatic::.- 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
mixedin 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'sSerializerInterface::deserialize(). - Method-level
@throwstypes now resolve short names to FQN. Exception types in@throwstags on class methods are now fully qualified using the file'suseimports, 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 ausestatement. - 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 anamespacedeclaration 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.
Helperwith 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
!== nullchecks. Null-initialized variables guarded by$var !== null,!is_null(), or bare truthy checks now havenullnarrowed away inside the then-body and in subsequent&&operands. Works in chained conditions, ternary expressions, and return statements. - Variables assigned inside
if/whileconditions now resolve in the body.if ($admin = AdminUser::first())andwhile ($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
nulland 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. @vardocblock annotations no longer leak across class and method boundaries. A@varannotation for a same-named variable in a different class no longer bleeds into the current scope.- Inline
@varcast 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$datausing the cast type. - Foreach over union types containing arrays now resolves the element type. A parameter typed
User|array<User>iterated withforeachnow correctly yieldsUseras the loop variable type. Previously the element type extraction did not look inside union members, producing no completions. @paramdocblock overrides ignored when the native type hint resolves. When a parameter has both a native type hint and a more specific@paramoverride, the docblock type now takes effect. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/55.- Variable reassignment inside
try/catch/finallyblocks 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.
instanceofnarrowing 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.stdClassandobjecttypes no longer produce false-positive diagnostics. Variables typed asobjectorstdClassnow permit arbitrary property access.is_object()correctly narrowsmixedtoobjectand compound&&conditions propagate the narrowing.- Docblock type refinement no longer matches class names containing type keywords. A class named
PointOfInterestwould incorrectly be treated as anintrefinement because the refinement check used substring matching. Refinement compatibility now uses structural type predicates. class-string<T>static method dispatch. Calling static methods on aclass-string<Foo>variable now resolves return types correctly, includingstaticsubstitution to the bound class.self/static/$thisin cross-file method return types now resolve correctly. When a method on a cross-file class returns a type referencingself(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_arrayguard 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
__callno longer lose the return type. When__callreturns$this,static, orself, the chain type is preserved through dynamic method calls. - Scope methods on Eloquent Builder no longer produce false-positive diagnostics. Bare
Builderreturn types on scope methods are automatically wrapped asBuilder<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 aBuilder<Product>chain now infers$qasBuilder<Product>, so model-specific scope methods resolve correctly. @seetags 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
staticreturn types on inherited methods. Methods returning?staticorstatic|nullnow 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
|nullafter template substitution.@return TValue|nullnow preserves|nullthrough substitution, so calls like::first()correctly show the nullable type. @mixinreferencing a template parameter now resolves. A class with@template Tand@mixin Tnow pulls in methods from the concrete type passed via generic arguments.@propertyand@methodtags losing nullable types. Tags like@property int|null $foono longer have|nullstripped.- Callable types inside unions displayed ambiguously.
(Closure(int): string)|Foois 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
@templatewith generic wrapper parameters. Template substitution at call sites now correctly handlesarray,iterable, andlistas wrapper names. - Closure parameter inference from function-level
@templatebindings. Functions likearray_anyandarray_allnow infer concrete types for untyped closure arguments from the array parameter's element type. - Property chain arguments in template substitution. Expressions like
$this->itemspassed 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 functionno longer flagged as unknown. Functions defined in one file and imported viause functionin another now resolve correctly. parent::method()return type resolution in variable analysis. Callingparent::method()and assigning the result now correctly resolves the parent method's return type.- Closure parameter inference inside
switchcases andifconditions. 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
@extendschains. 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 likeRelation<TRelatedModel, *, *>now parse correctly. - Types with
covariantorcontravariantvariance annotations in generic args now parse correctly. Annotations likeBelongsTo<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-sourceor 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
isprefix for getters. Properties typed?boolor?booleannow generateisFoo()instead ofgetFoo()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 returnarray<int, stdClass>instead of barearray, andDB::selectOne()returns?stdClass.- Redis
Connectionmethod resolution. Redis commands onIlluminate\Redis\Connections\Connectionnow 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
implementsrenders with strikethrough. Deprecated classes referenced inimplementsclauses are correctly tagged. - Interleaved array access and property chains no longer produce false positives. Expressions like
$results[$i]->activities[$id]->extraswhere 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>whereTmaps toCollection<string>), the substitution produced malformed types likeCollection<string><int>. The replacement's base name is now used correctly, yieldingCollection<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/phpstanor$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.jsonrequire-devautomatically 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
countandstrlen, 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/@implementstags. Uncaught exceptions get@throwswith 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 = truein 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
@paramtags, 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. @throwscode actions. Quick-fixes for adding missing and removing unnecessary@throwstags, triggered by PHPStan diagnostics. Adding inserts the tag and auseimport 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/includepaths are now Ctrl+Clickable. Path resolution supports string literals,__DIR__concatenation,dirname(__DIR__),dirname(__FILE__), and nesteddirnamewith levels. - Analyze command.
phpantom_lsp analyzescans 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--severityfiltering and--no-colourfor 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?Fooreturn type),nullis stripped from the LHS and the result is the union of the non-null LHS with the RHS. @mixingeneric 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
@varcompletion. Inline@varabove variable assignments sorts first and pre-fills the inferred type when available. Template parameters from@templateenrich@param,@return, and@vartype hints. @seeand@linkimprovements.@seereferences in docblocks now work with go-to-definition (class, member, and function forms). Hover popups show all@linkand@seeURLs as clickable links. Deprecation diagnostics include@seetargets when the@deprecateddocblock 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
.phararchives (e.g. PHPStan'sphpstan.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.tomlin 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.tomlis 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|falsein 7.x becomingintin 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 previousClass: ClassNamedetail 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
OrderputsOrderaboveOrderLineaboveCheckOrderFlowJobregardless 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
usestatement 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 legacydeprecatedboolean. 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
masterbranch 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
@paramtag 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
usestatement FQNs, preserves explicit aliases, and introduces an alias when the new name collides with an existing import. - False-positive diagnostics for
$thisinside traits. Accessing host-class members via$this->,self::,static::, orparent::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
$orderresolve 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
@paramannotations no longer produce a false "unknown class" diagnostic. - Removed PHP symbols in stubs. Functions, methods, and classes annotated with
@removed X.Yin phpstorm-stubs are now filtered out when the target PHP version is at or above the removal version. Previously symbols likemysql_tablename(removed in PHP 7.0) andeach(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$ambiguousisLamp|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. PreviouslyUser::find()would incorrectly showclass Usereven thoughfind()is declared onModel. - 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
foreachbody are now visible after the loop. - Variable-to-variable type propagation. Assignments like
$found = $pennow resolve$foundto the type of$pen. This also eliminates false-positive diagnostics when the initial assignment was$found = nulland a later reassignment provided the real type. - Variable type inside self-referencing assignment RHS. In
$request = new Foo(arg: $request->uuid), the$requestreference 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
useimports now resolve correctly in consuming files. Function parameter types and@throwstypes 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 refinestring, butarray<int>no longer incorrectly overridesstring). - 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
?ClassNameorCollection<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\ProvidervsConcrete\Provider). - Guard clause narrowing across instanceof branches. After
if ($x instanceof Y) { return; }, subsequentinstanceofchecks on the same variable no longer incorrectly resolve toY. instanceof self/static/parentnarrowing. Type narrowing withinstanceof self,instanceof static, andinstanceof parentnow works correctly in all contexts (assert, if-blocks, guard clauses, compound conditions).- Type narrowing inside
returnstatements.instanceofchecks in&&chains and ternary conditions now narrow the variable type when the expression is the operand of areturnstatement. - 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 likeself::Active->valueinside an enum method now resolve correctly. Previously,self,static, andparentwere only recognized as bare subjects, not when followed by::MemberNamein 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\Rewithuse 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.
@throwscontinues to use Throwable-filtered completion. - Trait alias go-to-definition. Clicking a trait alias (e.g.
$this->__foo()fromuse 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
morphedByManyrelationships. The inverse side of polymorphic many-to-many relationships is now recognised. Virtual properties and_countproperties 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.
@deprecatedtags and#[Deprecated]attributes surface in hover, completion strikethrough, and diagnostics. A quick-fix code action rewrites deprecated calls when areplacementtemplate 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.tomlfor per-project settings: PHP version override, diagnostic toggles, and indexing strategy. Runphpantom --initto 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.
@implementsgeneric 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
@templateparameters, bindings, conditional return types, and type assertions from their interfaces. - Function-level
@templatewith generic return types. Functions that use@templateparameters inside generic return types now resolve concrete types from call-site arguments. - Generic
@phpstan-assertwithclass-string<T>. Assertion methods that accept aclass-string<T>parameter resolve the narrowed type from the call-site argument. - Property-level narrowing.
if ($this->prop instanceof Foo)narrows$this->propin 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$xtoA|Bin 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 $classStringVarand$classStringVar::method(). Class-string variables resolve fornewand 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.jsonand filters built-in stub signatures accordingly. @param-closure-this.$thisinside a closure resolves to the type declared by@param-closure-thison 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
&$varparameter, the variable acquires that type. iterator_to_array()element type. Resolves the element type from the iterator's generic annotation.- Enum case properties.
$case->nameand$case->valueresolve on enum case variables. - Inline
@varon promoted constructor properties. Overrides the native type hint, matching existing@paramsupport. --versionand--helpCLI 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
namespaceline, actual default values,@linkURLs, precise token highlighting, constructor signatures onnew,@templatedetails, enum case listing, trait member listing, origin indicators, and deprecated explanations. - Signature help enriched. Compact parameter list with native types, per-parameter
@paramdescriptions, 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.
.gitignorerules 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
@methodand@propertytags across files. - Diagnostics refresh across open files when a class signature changes.
- Variable types resolve through ternary, elvis, null-coalesce, and match assignments.
instanceofnarrowing no longer widens specific types.- Elseif chain narrowing and sequential assert narrowing.
@phpstan-typealiases 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()andtryFrom()chaining. static/self/$thisin 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
@varannotations no longer leak across scopes. - Literal string conditional return types.
- Class constant and enum case assignment resolution.
- Go-to-definition on trait
asalias andinsteadofdeclarations. - Inline array-element function calls resolve correctly in diagnostics.
end($obj->items)->method()no longer produces a false diagnostic. - Double-negated
instanceofnarrowing. - 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,instanceofin ternaries and with interfaces. - Anonymous class support.
$this->resolves inside anonymous classes with full inheritance support. - Context-aware completions.
extends,implements,useinside 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,
staticreturn 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. InfersTfrom the call-site argument. @phpstan-type/@psalm-typealiases 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
@templatewith@extendssubstitution. Method-levelclass-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_oncediscovery, go-to type definition.
Fixed¶
@mixincontext 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/BackedEnuminterface members. - Go-to-definition. Classes, methods, properties, constants, functions,
newexpressions, variables. - Class name completion with auto-import.
- PSR-4 lazy loading and Composer classmap support.
- Embedded phpstorm-stubs.
- Zed editor extension.