Changelog
August 8, 2026 · View on GitHub
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
$thisin a Livewire view is the component. Livewire renders a template with the component instance bound, so a view reads its properties and calls its actions off$this. PHPantom had nothing to say about it: hover, completion, and go-to-definition on$thisin a Livewire view all came back empty. The template body is now analysed as a method of the component it belongs to, so$this->completes the component's own properties and actions along with everything it inherits, hover types them, go-to-definition jumps to the declaration, and a chain that starts at$thiscarries its type through the rest of the expression. Views that Livewire does not back are untouched: Laravel renders an ordinary Blade component's view through the view engine, where$thisis not the component.- Shared and composed template variables come from the provider that registers them.
View::share('siteName', …)puts a variable in every template's scope andView::composer('partials.*', …)puts one in the scope of the views it targets, but neither is written in a template or passed by anyview()call, so a template that read one reported it undefined. PHPantom now reads both from your service providers, whether they are written against theViewfacade or the container's own view factory ($this->app['view']->share(…),app('view'),view()), and covers all the registration shapes: a key and a value, a whole array, an inline closure whose$view->with(…)calls supply the data, a composer class whosecompose()body does, and theView::composers([Composer::class => 'view.name'])table. The types come from resolving the shared expression itself, so a shared model keeps its class. A composer only reaches the views its pattern matches, with*matching the way Laravel's ownStr::is()does. These sit below the template's own declarations and the variables Blade injects into a component body, so a shared variable namedslotnever displaces the real one, and above the types inferred from call sites. - View names and component tags resolve through one project-wide index. Laravel addresses a template by a dotted view name and a component by a tag name, and both are transforms of a file path rather than anything written in the code. PHPantom now builds that index once, over the view roots
config/view.phpconfigures, the view directories packages register, and the namespaces class-based and Livewire components live in, including the ones a service provider registers withBlade::componentNamespace(…)and a customlivewire.class_namespace. View name completion and go-to-definition read the index instead of walking the project per request, and it is rebuilt as you add or move files. One thing it finds that name-guessing could not: an index component, addressed by its directory alone (<x-card>backed byApp\View\Components\Card\Card,<livewire:posts>byApp\Livewire\Posts\Index), now supplies its template's variables like any other component class. - A custom
Storage::extend()driver no longer costs the rest of the project its disk type.Storage::disk()and friends declare only theFilesystemcontract, and PHPantom resolves them to what the disks inconfig/filesystems.phpare really built from. Until now a single disk on a driver the framework does not ship was enough to give that up for every disk in the project, because the driver's type was unknowable. PHPantom now reads theStorage::extend('name', function (…) { … })registration in your service providers, so the disk it backs resolves to whatever that closure builds. The documented registration shape returns aFilesystemAdapter, in which case the custom disk resolves to the same concrete adapter as the built-in ones; a driver that builds something else widens the disk type to include it instead of dropping the correction. A registration named after a built-in driver replaces it, the way the container does. - A component's own class supplies the variables its view reads. Blade merges a class component's public properties and its public argument-less methods into the data the view renders with, and Livewire hands its view the component instance, so a component template that reads one of those had no way to know what it was and reported it undefined. PHPantom now resolves the class behind a component view the way Laravel does, over the class-component namespaces a service provider registers (
Blade::componentNamespace(…)), theApp\View\Componentsconvention, and the configured Livewire class namespace, and puts its members in the template's scope. They sit below the template's own@bladestan-signature,@props, and@awaredeclarations and above the types inferred from call sites, so a template that documents a name keeps its own type for it. Members the framework base class declares stay out, as does any method that requires an argument, matching what Blade actually exposes. - String container bindings resolve to their bound class. A service provider's
$this->app->singleton('sentry', fn () => new HubAdapter()),bind('key', Concrete::class),instance('key', new Concrete()), andalias(Concrete::class, 'key')are now indexed, soapp()->make('sentry'),app('sentry'), andresolve('sentry')resolve to the bound class the same way a::classargument does. The key does not have to be written as a literal: a package that keeps it in a class constant or a static property on the base provider its subclass extends ($this->app->alias(HubInterface::class, static::$abstract)) is read the same way.extend()calls are skipped, since they decorate whatever the key already holds rather than replacing it. When more than one provider binds the same key, the key resolves to the class the container would end up holding: an application's registration replaces a framework or package default, and a provider that subclasses another replaces the binding it inherited, so swapping an implementation out (rebinding'translator'from your ownTranslationServiceProvider, say) resolves to the replacement rather than the class it replaced. - A Blade template's variables come from one declared priority chain. What a template gets in scope is now resolved the way Bladestan (the PHPStan extension for Blade) resolves it, so one set of annotations drives both the editor and CI. A
@bladestan-signaturedocblock is the template's contract; without the marker the first docblock before any template code serves as one. Below that,@propsand@awaresupply the names the contract leaves out, each typed from its default value, and a component view also receives the variables Blade injects into it, now including$componentNamealongside$attributesand$slot. Types inferred fromview()call sites remain the last resort. Each source only fills in what the ones above it did not declare. A directive Blade itself ignores no longer declares anything either: a@propswritten inside a comment, a@verbatimblock, or a PHP string literal is inert, so it neither declares props nor marks the template as a component. - Blade templates infer their variables from
view()call sites. A template with no@vardeclarations of its own now gets its variable types from the places that render it:view('name', ['user' => $user])array literals,compact()arguments, and->with('key', $value)chains, includingView::make(). Variables passed at several call sites union their types, and completion, hover, go-to-definition, and undefined-variable diagnostics inside the template all see the inferred set. Declared@varannotations still take precedence: a template that documents its own contract is left untouched. Closes #296. - Analyze verbosity flags.
phpantom_lsp analyzenow supports PHPStan-style--debugand-v/-vv/-vvvflags.--debugprints each file as it is analyzed and disables the progress bar, so a hang or slowdown is immediately attributable to a specific file; warnings about unusually slow files also moved under this flag.-vadds per-file durations and a phase timing summary,-vvadds worker ids and parse-phase tracing, and-vvvadds memory usage. - Config return type inference.
config('database.default'),Config::get('app.name'), and$repository->get('mail.from')now infer their return type from the project'sconfig/*.phpfiles. Scalar values resolve to their base type (string,int,bool),env()defaults resolve through their fallback argument, and nested arrays resolve to array shapes with typed keys. Framework default configs fromvendor/laravel/framework/config/fill in any keys the project's own config file leaves unset, so a partially publishedconfig/app.phpstill resolves the framework defaults it does not override. Parsed config trees are cached and invalidated when config files change. Contributed by @calebdw. - 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 semantic-token stream andoffdisables semantic tokens. Contributed by @calebdw. @phpstan-ignoreidentifiers are highlighted and completed. PHPStan ignore comments now highlight the@phpstan-ignoretag and each listed error identifier in both docblocks and ordinary//comments. Identifier completion works inside the comma-separated ignore list, using PHPStan diagnostic codes already seen in the current file while staying out of per-code parenthesized reasons. Contributed by @calebdw.- Member completion at the class-body root. Triggering completion directly inside a class body, before typing any modifier, now suggests every parent, interface, and trait member the class can still override or implement. Each suggestion inserts the complete declaration, so
oofferspublic function onChange(callable $callback): selffrom the get-go. Member keywords (public,function,const, ...) are offered alongside, while class names, functions, and global constants, which are invalid at that position, no longer appear. Preceding docblocks, comments, and attributes are skipped, so the suggestions still appear on the line under them, and the body of an anonymous class declared inside a method counts as a class root too. - Parent member override completion. Typing a method name after
functionin a class body (for exampleprotected function get) now suggests public and protected methods from parent classes and interfaces that can still be overridden or implemented, inserting a full signature snippet. The same flow suggests parent properties after$(for exampleprotected $tit) and constants afterconst, drawing those from interfaces and traits as well as parent classes on the PHP versions that allow redeclaring them. Snippets insert#[\Override]above methods on PHP 8.3+, properties on PHP 8.5+, and constants on PHP 8.6+ (fromcomposer.json/config.platform.php). Private members and ones already defined on the class are omitted. Contributed by @calebdw. - Reference and implementation count inlay hints. Classes, interfaces, traits, enums, methods, properties, and constants now show reference counts (e.g. "3 references") as inlay hints. Interfaces and abstract classes additionally show implementation counts (e.g. "2 implementations"). Counts are derived from the cross-file reference index and the go-to-implementation reverse inheritance index, so they are fast even on large codebases. Private members, magic methods, and overridden members are omitted to keep the annotations focused. Contributed by @calebdw.
- Trait member override completion. Typing a method or property name after
functionor$in a class that uses a trait now suggests the trait's public and protected members as override candidates. Unlike parent/interface overrides, trait method replacements do not insert#[\Override](which would be a compile error for trait-only methods). Classes that only use traits without extending a parent or implementing an interface also receive suggestions. Contributed by @calebdw. - Laravel schema dumps power Eloquent model properties. PHPantom now scans Laravel database schema dumps from
database/schemaby default, readsconfig/database.phpfor connection drivers/defaults, and uses the parsed columns to synthesize Eloquent model properties with database types, nullability, and defaults in hover. Schema lookup respects model$connection,$table, LaravelConnection/Tableattributes, dynamic table/connection overrides, and reloads when schema files or related config change. Contributed by @calebdw. - Laravel migration scanning for Eloquent model properties. PHPantom now parses Laravel migration files to infer database columns when schema dumps are not available or to overlay changes on top of dumps. Migrations are discovered from any non-vendor
database/migrationsdirectory (including nested modules likemodules/billing/database/migrations), applied in global filename order, and support named and anonymous migration classes,$connectionproperties,Schema::connection()calls,Blueprint::after()nested closures,virtualAs/storedAsgenerated columns, and custom Blueprint macros registered via the existing macro scanner. Migration scanning is incremental: editing a single migration re-reads only that file and replays the cached plan over the base schema without re-reading other files. Configure with[laravel.migrations] enabledandpathsin.phpantom.toml. Contributed by @calebdw. - By-reference closure captures update outer variable types for immediately-invoked callables. A closure passed to a callable parameter can now update the inferred type of variables captured with
use (&$var)when the callable is considered immediately invoked. This follows PHPStan's defaults: function callable parameters are immediate unless marked with@param-later-invoked-callable, while method callable parameters are later-invoked unless marked with@param-immediately-invoked-callable. Contributed by @calebdw. - Full workspace indexing. PHPantom now parses every PHP file in your project in the background after startup by default (
strategy = "full"in.phpantom.toml), building complete symbol data and a cross-file reference index. Find References, Rename, Go to Implementation, and Type Hierarchy now resolve against the whole project instead of only files you've opened, and these searches scan only the files known to actually reference the symbol rather than every open file. The indexing progress bar processes larger files first so it reflects real progress instead of stalling near the end on a long tail of big files. Lighter modes (strategy = "composer","self", or"none") remain available for projects that prefer a smaller footprint. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/186. - Package resource discovery from service providers. Config keys, view templates, translation keys, and named routes registered by installed packages are now discovered automatically. The scanner reads
mergeConfigFrom(),loadViewsFrom(),loadTranslationsFrom(),loadJsonTranslationsFrom(), andloadRoutesFrom()calls in service providers (and their one-level-deep helper classes), resolves__DIR__-relative paths to the actual package files on disk, and feeds the results into the existing string key infrastructure. Completion now offers package config keys (e.g.config('horizon.environments')), namespaced view templates (view('horizon::layout')), namespaced translation keys (trans('package::file.key')), and package-defined named routes. Go-to-definition on any of these jumps to the exact key or file in the vendor package. Contributed by @calebdw. - Routes registered from a service provider are recognized. A project that keeps its route files outside the conventional
routes/directory, and wires them up from a service provider, now has those routes discovered whichever way the provider reaches them:$this->loadRoutesFrom(…), the fluentRoute::…->group(base_path('…'))API, or a plainrequire/includeinside aRoute::group([…], function () { … })body. The name and URI prefixes of the enclosing group carry into the included file, and registrations written inside anifblock, a loop, or a provider method are picked up rather than skipped. Named routes defined that way resolve, complete, and hover, go-to-definition jumps to the declaration, androute('…')calls naming them are no longer reported as unknown. - Laravel string key completion (route, config, view, trans). Typing inside the first string argument of
route(),to_route(),config(),Config::get(),view(),View::make(),__(),trans(),Lang::get(), and related helpers now offers autocompletion from the project's actual route names, config keys, view templates, and translation keys. Route names are collected fromroutes/*.php(including group prefixes andRoute::group([], __DIR__ . '/sub.php')file includes),Route::resource()andRoute::apiResource()generate conventional named routes (index,create,store,show,edit,update,destroy) respecting->only()and->except()modifiers, config keys fromconfig/*.phparray declarations, view names fromresources/views/file paths, and translation keys fromlang/files. Go-to-definition on route names also follows file includes and resolves resource routes. Laravel container attributes (#[Config('key')],#[Database('conn')],#[Cache('store')],#[Log('channel')],#[Storage('disk')],#[Auth('guard')]) offer completion from the relevant config sub-keys (e.g.#[Database('')]shows database connection names fromconfig/database.php). Facade methods likeAuth::guard(),DB::connection(),Cache::store(),Log::channel(),Storage::disk(), and theauth()helper also complete from their respective config sub-keys. Contributed by @calebdw. - Hover for Laravel string keys. Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g.
routes/web/ems.php,config/app.php). Contributed by @calebdw. - Diagnostics for invalid Laravel string keys. A typo in
route('dashbaord'),config('app.naem'),view('layouts.ap'), or__('auth.failedd')now produces a warning:Unknown route: 'dashbaord'. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. - Artisan command names and signature strings. Command names declared by a command class's
$signature,$name, or#[AsCommand]attribute are now recovered from project and vendor command classes, so referencing one as a string completes, navigates to the declaring class, hovers with its arguments and options, and flags unknown names. This coversArtisan::call(),Artisan::queue(),Schedule::command(), and$this->call()/$this->callSilently()inside a command. The$signaturegrammar ({user},{user?},{--queue=}, arrays, defaults, shortcuts, and:descriptions) is parsed so that, inside a command,$this->argument('user')and$this->option('queue')complete and hover against that command's own signature and unknown parameter names are flagged, and the parameter array ofArtisan::call('cmd', [...])completes the target command's argument and--optionkeys. Contributed by @shuvroroy (#274). - Route parameter names complete from the route's URI. The parameters array of
route('users.show', ['user' => $user])is keyed by the{parameters}of the route's URI, so PHPantom now offers those keys as completion, into_route(),signedRoute(), andtemporarySignedRoute()as well. The URI comes from the registration the route name was declared on (Route::get('/users/{user}/posts/{post}', …)->name('users.posts.show')), together with every URI prefix that registration inherits, so nested and grouped routes offer the full parameter set. Optional markers and scoped bindings ({post:slug}) are reduced to the key Laravel actually accepts. Contributed by @shuvroroy (#301). - Resource route URIs.
Route::resource()andRoute::apiResource()write no URI of their own, so the routes they register had none and their parameters did not complete. PHPantom now derives the URI Laravel does from the resource name —Route::resource('photos', …)givesphotos,photos/create, andphotos/{photo}— soroute('photos.show', ['photo' => $photo])completes its parameter like any other route. A nested name singularizes each parent segment (photos.commentsbecomesphotos/{photo}/comments/{comment}),->parameters(['photos' => 'grid'])and->parameter()replace a derived name,->shallow()reduces the routes that identify a record by its own id, and a slash in the resource name is read as a URI prefix. Resource registrations written as a chain link (Route::prefix('admin')->resource(…)) are now recognized too, picking up the URI prefix of their enclosing groups and the route-name prefix of an->as()on their own chain, and->only(),->except(), and->names()are read the way the framework combines them. Contributed by @shuvroroy (#308). - Request input keys complete from validation rules. The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a request accessor names a field:
$request->input('key'), the typed and presence accessors, the multi-key ones such asonly()andexcept(),safe()->only([...]), and array access$request['key']. The rules come from therules()method of theFormRequesttype-hinted in the enclosing method (followingarray_merge()and the parent chain), or from avalidate()/Validator::make()call earlier in the same method. Each suggestion shows its rule (required|string|max:255), and go-to-definition on the key jumps to the line that declares it. Wildcard rules likeitems.*.idcomplete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292). validated()is typed from the validation rules.$request->validated()is declared to returnarrayand no tool takes it further, but the rules array already says which keys the result holds and what each one is. PHPantom now translates it into an array shape, so$data['title']is astring,$data['views']is anint,'nullable'addsnull, a field that is neither required nor nullable becomes an optional key,'items.*.id'produceslist<array{id: int}>, and an'image'rule gives you a realUploadedFileto chain from. The keys complete on array access and the whole shape shows in hover. This covers$request->validate([...])return values,$validator->validated()where the rules are visible,validated('key')for a single member, andsafe()->only([...])/except([...]), which narrow the same shape. When the key set cannot be read in full the shape is abandoned for plainarrayrather than claiming a key set it cannot vouch for, which covers a computed rule key as well as a computed argument:validated($key)still gives you one field's value rather than the whole array, andonly($keys)keeps its declared type instead of narrowing to a guess. Contributed by @shuvroroy (#294).- Laravel model factory relationship methods. Eloquent factories now offer the dynamic
has{Relationship}()andfor{Relationship}()methods that Laravel resolves throughFactory::__call(), one per relationship on the associated model, plustrashed()when the model usesSoftDeletes. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, soPost::factory()->hasComments(3)->create()still resolves to the model. The model is derived from the factory naming convention, so no@extends Factory<Model>generic is required. Contributed by @shuvroroy (#260). - Eloquent
$pivotattribute on many-to-many related models. Models that are the target of abelongsToMany/morphToManyrelationship now expose a$pivotproperty, so accessing the intermediate row (e.g.$user->roles->first()->pivot) completes, hovers, and resolves. The pivot type is taken from the relationship'sTPivotModelgeneric (BelongsToMany<Permission, $this, PermissionRole>), falling back to a->using(CustomPivot::class)call in the relationship body and then to the base\Illuminate\Database\Eloquent\Relations\Pivot; a declaredpivotproperty still takes precedence. Only models actually reached through such a relationship gain the attribute, and the->withPivot('col', …)columns and custom pivot class are also shown when hovering the relationship. Contributed by @shuvroroy (#266). - Laravel
Macroable::mixin()registrations are recognized. AStr::mixin(new StrMixin())orCollection::mixin(CollectionMixin::class)call in a service provider now contributes one macro per public/protected method of the mixin class, taking the signature of the closure that method returns, so those methods autocomplete, hover, resolve, and type-check on the target class just like aTarget::macro(...)registration. Mixins registered through a facade also attach to the facade's concrete container-bound class, and go-to-definition on a mixed-in method jumps to the mixin method's own declaration. Editing the mixin class (for example adding a helper method) refreshes the recognized macros. Contributed by @shuvroroy (#256). - Carbon trait-based
mixin()registrations are recognized. Carbon'smixin()supports traits in addition to classes (since Carbon 2.23.0), where the trait's public methods become methods on the target directly rather than acting as closure factories.CarbonImmutable::mixin(MyTrait::class)now synthesizes one macro per qualifying trait method using only the trait method's own signature, and Carbonmacro()calls (e.g.CarbonImmutable::macro('name', fn () => ...)) also resolve through the same pipeline as Laravel'sMacroable. Contributed by @calebdw. @phpstan-require-implementscontributes to trait$thisresolution. Traits annotated with@phpstan-require-implements InterfaceNamenow resolve$thisagainst the required interface inside trait methods, matching the existing@phpstan-require-extendsbehavior for required base classes. This makes required interface methods available in completion, hover, and member resolution while editing the trait itself. Contributed by @calebdw.- Larastan
model-property<Model>type validation and completion. Themodel-property<Model>pseudo-type is now resolved against the model's known properties during argument type checking. String literals that do not match a declared or virtual property are flagged as type mismatches, while non-literal strings are accepted conservatively. Typing inside a string argument whose parameter is typed asmodel-property<Model>now offers completion of the model's property names. The type parser now also handles hyphenated generic pseudo-types that the PHPDoc type grammar does not recognise natively. Contributed by @calebdw. - Eloquent morph map aliases. A
Relation::morphMap(['post' => Post::class, …])orRelation::enforceMorphMap([…])call in a service provider is now recovered, so the short aliases it registers behave like real symbols wherever they appear as string literals. Hover names the model an alias maps to and the file that registers it, go-to-definition offers both the registration and the model, and find-references links every usage back to the registration. The alias positions Eloquent actually resolves through the map are recognized: the registration's own keys,Relation::getMorphedModel(),Model::getActualClassNameForMorph(), and the$typesargument of thewhereHasMorph()family (including the'*'wildcard and class-name spellings, which are left alone). Laravel's list shorthandRelation::morphMap([Post::class, …])is understood too, keyed by each model's table name the way the framework derives it. When the project callsenforceMorphMap()(orrequireMorphMap()) the map is the exhaustive set of morph types, so an alias that is not registered is flagged; without it the set stays open and nothing is reported. - Workspace-wide diagnostics. PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with
[diagnostics] workspace = false(native pass) orworkspace-external = false(external tools) in.phpantom.toml. - Higher-order collection proxies.
$users->map->emailis Laravel shorthand for$users->map(fn ($u) => $u->email), and PHPantom now types it that way: the item type's members complete and hover through the proxy, and each one resolves to whatever the proxied collection method returns for it —mapcollects the member,filterandeachkeep the collection,firstgives you one nullable item,containsabool,suma number. The result is an ordinary collection, so the chain continues from it. The proxy remembers which collection it came from, so a method that returnsstaticstays on an Eloquent or application-defined collection, while mapping to a value that is not a model falls back to the base collection exactly as Eloquent does at runtime. Contributed by @shuvroroy (#314). - Enum validation rules type their field. A
validated()array shape used to give up on an enum rule and call the fieldmixed, because the rule is an object (new Enum(Role::class),Rule::enum(Role::class)) rather than a rule name. The field now takes the enum's backing type (stringfor a string-backed enum,intfor an int-backed one), which is what the validated array actually holds, since it carries the raw input rather than the enum case. The enum is read the same way whichever spelling it has, including a fluent chain likeRule::enum(Role::class)->only([…]), and its class name is resolved against the imports of the file that declares the rules, so aFormRequest's ownusestatements are what count. A pure enum has no raw scalar form, so those fields staymixedrather than being guessed at. Contributed by @shuvroroy (#307).
Changed
- 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 (template substitution, trait and interface merging, Eloquent forwarding) 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.
- 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. Edits still re-resolve only the affected classes.
- 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.
- Property hover now shows effective types as a
vardetail line. Property hovers now mirror method hovers by displaying the resolved/effective property type above the PHP snippet as**var**, while the snippet itself shows only the native PHP property declaration. This keeps docblock-inferred, virtual, and schema-derived property types out of the generated signature block. Contributed by @calebdw. - 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.
- PHPDoc comments and the types inside them are now parsed by one unified parser. Tags written with a
@psalm-or@phpstan-prefix are now 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-sealedand@template-extendsare understood everywhere the plain spelling was. Variance annotations (covariant,contravariant) in generic arguments are parsed directly rather than stripped beforehand, which makes docblock go-to-definition and rename land on the right text in types that use them. A tag indented with more than one space after the*is no longer dropped, and a docblock you are still typing (a bare@, a half-written type, no closing*/) now yields the tags above the cursor instead of nothing, so@param,@return, and@throwscompletion keeps working mid-edit. - PHPDoc tags are read directly from the parsed grammar instead of being scanned again as text. Types written across several lines are now handled the same everywhere, so a multi-line
@property,@mixin, or@templatebound resolves like its single-line form, and trailing prose no longer leaks into a@phpstan-typealias definition or a@methodparameter type. Tags the grammar cannot parse still fall back to the old scan, so half-typed and non-standard annotations keep working. - 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. @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 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 (e.g. a__toStringdeclared only on an implemented interface). - Faster Laravel string-key diagnostics. Checking a
route(),config(),view(), or__()key needs the project's full list of valid keys. That list is now built once and shared instead of being rebuilt redundantly during the diagnostic pass, cutting whole-project analysis time by around 15% on large Laravel projects. - 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.
- Faster whole-workspace class pre-resolution. Resolving every known class in dependency order after indexing now spreads across multiple workers instead of running on a single one, substantially cutting the pause between indexing and diagnostics on large Laravel projects. The editor's post-startup pre-resolution goes through the same path, so a large project becomes fully warm sooner.
- Project startup is significantly faster. Building the class index now reads files normally instead of memory-mapping them, and the work is parallelized more effectively across your CPU cores. This meaningfully cuts both startup time and CPU use on large Laravel projects, speeding up whole-project analysis and getting the editor ready sooner, with no change in results.
- 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-projectanalyzeandfixruns. The editor's LSP session is unaffected. - Faster project startup. The parts of startup that still ran on a single core now use all of them. Every autoload directory in the project, 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 now 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. - Class origin classification no longer re-scans the whole classmap after the fact. Startup used to look up each discovered class's completion-ranking origin (project, explicit dependency, transitive dependency, core stub) 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, removing both the duplicate parse and the serial pass. - 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, cutting a measurable share of the walk on methods with many locals and many statements. - Faster diagnostics on method/function calls that resolve to no concrete class. Checking whether such a call's result was actually a bare
object/?object(still valid for member access) used to re-resolve the callee's whole receiver chain and method signature a second time from scratch. That check now reuses the resolution already performed, roughly halving diagnostics time on files with many unresolved or missing-method call chains. - 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 the cost of checking one call grew with the size of the file around it and 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, and analysing the project it belongs to is close to three times faster.
- 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.
- Faster Eloquent scope-method resolution. Injecting a model's scope methods onto its Builder used to re-walk the model's full inheritance chain (traits and parent classes) 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 instead of repeatedly. - 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 (a class, one of its members, a function, a constant) and re-analyses only the open files that mention one of those names. Editing a method body reaches only the files that use the class, and renaming or retyping a member reaches only the files that use that member; unrelated tabs are left alone, so 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.
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
Storage::disk()and friends resolve to the concrete adapter instead of the bare contract.FilesystemManager::drive()/disk()/cloud()/build(), and theStoragefacade's matching@methodtags, only ever declared the abstractFilesystem/Cloudcontract, even though every driver the framework ships builds a concreteFilesystemAdapter. Adapter-only members likeassertExists()in a test'sStorage::fake()/disk()chain, ordownload()on a controller's configured disk, were reported as missing.config/filesystems.phpis now read to confirm every configured disk uses a driver the framework ships (local,ftp,sftp,s3,scoped); when it does, the four methods resolve to the concrete adapter instead. A disk built by a customStorage::extend()driver, whose return type cannot be read statically, leaves the declared contract untouched rather than risk a false member. Contributed by @AJenbo.- A command that declares both a signature and
#[AsCommand]is indexed under the name Artisan answers to. The attribute was read first, so a class carrying#[AsCommand(name: 'x:from-as-command')]alongsideprotected $signature = 'x:from-property'was indexed asx:from-as-command, a name Artisan never registers. Call sites spelling the command the way Artisan does were reported as unknown, and go-to-definition on the indexed spelling landed on the attribute rather than the signature. The name is now taken from the signature first, then$name, then#[AsCommand], matching the orderCommand::__construct()applies at runtime. Contributed by @AJenbo. - Commands whose class name does not end in
Commandare found. A package that names its command classes after the action alone and groups them in aCommands/directory, asmonicahq/laravel-cloudflaredoes withsrc/Commands/Reload.php, contributed nothing to the index, socloudflare:reloadwas flagged as an unknown command even though Artisan lists it. Classes in aConsole/,Commands/, orCommand/directory are now scanned regardless of their name, and an edit to one of those files refreshes the index the same way. Contributed by @AJenbo. - 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. Storage::disk()and friends resolve to the concrete adapter instead of the bare contract.FilesystemManager::drive()/disk()/cloud()/build(), and theStoragefacade's matching@methodtags, only ever declared the abstractFilesystem/Cloudcontract, even though every driver the framework ships builds a concreteFilesystemAdapter. Adapter-only members likeassertExists()in a test'sStorage::fake()/disk()chain, ordownload()on a controller's configured disk, were reported as missing.config/filesystems.phpis now read to confirm every configured disk uses a driver the framework ships (local,ftp,sftp,s3,scoped); when it does, the four methods resolve to the concrete adapter instead. A disk built by a customStorage::extend()driver, whose return type cannot be read statically, leaves the declared contract untouched rather than risk a false member.- A command that declares both a signature and
#[AsCommand]is indexed under the name Artisan answers to. The attribute was read first, so a class carrying#[AsCommand(name: 'x:from-as-command')]alongsideprotected $signature = 'x:from-property'was indexed asx:from-as-command, a name Artisan never registers. Call sites spelling the command the way Artisan does were reported as unknown, and go-to-definition on the indexed spelling landed on the attribute rather than the signature. The name is now taken from the signature first, then$name, then#[AsCommand], matching the orderCommand::__construct()applies at runtime. - Commands whose class name does not end in
Commandare found. A package that names its command classes after the action alone and groups them in aCommands/directory, asmonicahq/laravel-cloudflaredoes withsrc/Commands/Reload.php, contributed nothing to the index, socloudflare:reloadwas flagged as an unknown command even though Artisan lists it. Classes in aConsole/,Commands/, orCommand/directory are now scanned regardless of their name, and an edit to one of those files refreshes the index the same way. - Attributes passed to an anonymous component no longer need
@propsjust to exist. Laravel merges every attribute written on an<x-…>tag into the component's own variable scope;@propsonly supplies defaults and removes the key from$attributes. PHPantom only created the variable when@propsnamed it, so<x-brand.boxes :hairAnalysis="$model->hairAnalysis" />read as$hairAnalysisinsideboxes.blade.phpreported an undefined variable unless the component redundantly declared it. The variables each<x-…>call site passes are now inferred the same wayview()call sites already are: a bound attribute's expression is typed from the caller, a plain string attribute is typedstring, and a hyphenated attribute name (hair-analysis) is read under the camelCase name Blade actually exposes it as.@props/@awarestill win over the inferred type for the same name. - 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 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 command declared with the
#[Signature]attribute is a known command. Laravel 13's#[Signature('app:sync {--limit=}')]attribute is now read the same way as the$signatureproperty, so referencing such a command no longer reportsinvalid_laravel_command, and its name and parameters complete, hover, and navigate like any other. When both surfaces are present the attribute wins, matching what the framework does at runtime. Inline arguments after the command name (Artisan::call('app:sync --limit=50')) no longer confuse the check either: only the leading token is the command name, so a valid command with arguments passes and a typo in the name itself is still caught. Closes #331. - 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. - A
@propslist no longer overrides the types a template declares. A component that declared its contract in a docblock and then listed the same names in@propskept the declared type for the first name only; every later one was bound tonull, so passing it anywhere reported "expects …|string, got null".@propsnow only supplies what the contract leaves out. A prop with no default is required, meaning its value comes from the caller, so it is no longer invented asnull. A component with no docblock at all therefore stops reporting a type error at every use of its own props. - A
@props-declared key is no longer reported as an unused variable. Naming a key in@propsis what removes it from$attributes, so deleting a prop the body never reads changes the rendered output. It is a component-API decision rather than a dead local assignment, and the unused-variable check no longer claims it. 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.- An array literal argument binds a union parameter hint's element type too. A parameter hint that offers "the element or a container of elements" (
@param iterable<array-key, TWrapValue>|TWrapValue $value, as Laravel'sCollection::wrap()declares) already bound the template one level deeper for a container argument typedarray<string>, but an array literal argument resolved to a barearraywith no element type before it ever reached that check, so nothing useful bound andWrapper::wrap(['a', 'b'])->push([1])raised no diagnostic. The literal's elements are now unwrapped the same way they already were for a plain generic-wrapper parameter, so a literal argument binds the template correctly through the iterable alternative. - 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 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. - Translation keys are no longer judged when the application loads them from elsewhere. An application that keeps its strings in a database replaces Laravel's file-based translation loader, but the
lang/directories the framework and its packages ship are still on disk. That was enough for PHPantom to consider the set of keys complete, so every one of the application's own keys was reported as unknown. A service provider that rebindstranslatorortranslation.loaderto anything other than Laravel's own loader now puts the valid keys out of reach, and the check is skipped rather than guessed at, the same way an unenforced morph map is. - An assignment in the inline
@php(…)directive is now recorded. Blade accepts both a@php … @endphpblock and the shorter@php($total = $order->items->sum('price')), but only the block form updated what PHPantom knew about the template's variables. A variable first assigned in the inline form had no type from then on, so every member reached through it was reported as unverifiable and neither completion nor go-to-definition worked on it. The inline form now updates the scope the same way the block does. The same fix applies to a parenthesized assignment written by hand in ordinary PHP, such as($total = compute());. - A Blade component attribute may wrap over several lines. The expression behind a
:attribute="…"binding was read only as far as the end of the line it started on, so the long array or argument list a formatter wraps was cut off mid-syntax. What remained was invalid PHP, which reported a syntax error at the wrap point, and a call that lost its later arguments was reported as having too few of them. The whole expression is now read, however many lines it spans, so wrapped bindings type-check and their variables hover, complete, and navigate on every line. 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. $attributesand$slotare recognized in Blade components. Laravel puts both in scope of every component view, but PHPantom knew about neither, so a component template reportedUndefined variable '$attributes'on the tag it merges its classes into and could not resolve anything reached through either name. A template that lives in acomponentsdirectory, or that uses@propsor@aware, now starts with$attributestyped asIlluminate\View\ComponentAttributeBagand$slotasIlluminate\View\ComponentSlot, so$attributes->merge([...])and$slot->isEmpty()resolve, complete, and hover. An ordinary view is unchanged:$slotthere is still undefined, because Laravel does not pass it one.@propsdeclares its keys as local variables. An anonymous Blade component receives every attribute the caller passes as a local variable, and@props(['caption' => ''])declares those variables explicitly with a default. PHPantom did not read the declaration, so every use of a declared prop was reported asUndefined variable. Each key is now declared as a local variable assigned its default value (ornullfor a defaultless prop, e.g.@props(['visible'])), and the array can span multiple lines as it usually does. Attributes with no@propsdeclaration still depend on the caller's<x-… :foo="$bar" />tag, which is unrelated future work.- Route names built with a string function are no longer reported as unknown. A route file that normalizes a loop element before naming the route (
$slug = preg_replace('#^/xmas/#', '', $subcategory); Route::get(...)->name('events.xmas.' . $slug);) contributed no name at all, because the route-name evaluator stopped at any function call. It now folds calls totrim/ltrim/rtrim,strtolower/strtoupper,ucfirst,str_replace,implode,sprintf, andpreg_replacewhen every argument is already statically known, soroute('events.xmas.gift-sets')and similar names resolve instead of being flaggedinvalid_laravel_route. A call outside that list, or with an argument that cannot be folded, still contributes no name rather than a partial or wrong one. $thisinside a 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.- Provider resource paths behind a local variable are now resolved.
mergeConfigFrom(),loadViewsFrom(),loadTranslationsFrom(), andloadRoutesFrom()only recognized their path argument when it was written inline as__DIR__ . '...',base_path(...), or a string literal. Livewire's own service provider (and others written the same way) assigns the path to a local variable first,$config = __DIR__.'/../config/livewire.php'; $this->mergeConfigFrom($config, 'livewire');, so the whole config file was silently skipped and every key it defines reportedUnknown config key. The path argument is now traced back to its most recent assignment in the enclosing method, so the package's config, view, translation, and route files are discovered the same as when written inline. - Included route file paths behind a local variable are now resolved. A route file that keeps the path of the file it pulls in in a variable,
$routes = __DIR__.'/../routes/api.php'; Route::group(['prefix' => 'v1'], $routes);orrequire $routes;, never had that file opened, so every route defined in it was invisible:route('api.v1.users.index')was reported as unknown, completion offered nothing from it, and go-to-definition had nowhere to jump. The include target is now traced back to its most recent assignment, at the top level of a route file as well as inside a service provider method, and the file is scanned with the name and URI prefixes of the enclosing group in force. An assignment written inside a function or closure is not read at file scope, since PHP would not see it there either. - Routes registered by a router macro are recognized.
laravel/uishipsRoute::auth()as a macro whose body registerslogin,logout,register, and thepassword.*routes, and any project can add macros of its own the same way. The route collector only read registrations written in a route file, so a route file callingRoute::auth()contributed none of those names and everyroute('password.update')was reported as unknown. A call to a router macro is now expanded against its registered body, whether it was registered withRoute::macro()or throughRoute::mixin(), and whether it is called on the facade, on the router itself from inside another macro, or through Laravel's ownAuth::routes(), which forwards to the router'sauthmacro. The name and URI prefixes in force at the call site carry into the macro's routes, soRoute::name('admin.')->auth()and a call inside a group both name them correctly, and go-to-definition on one jumps to the line of the macro body that declares it. - Route names built in a loop are no longer reported as unknown. A route file that registers one route per entry of a literal array names each of them by interpolation, so the name is not a plain string literal and nothing was recorded for it: every
route('…')naming one was flagged, none of them completed, and go-to-definition found nothing. Route names and URIs are now folded from the values that are statically known, so aforeachover a literal array (nested loops included) registers the routes it really does, whether the name is interpolated (->name("events.{$event}.landing")) or concatenated. The array may be built in a variable first and read back by key, and anything the fold cannot reach, a function call or a value from configuration, still contributes no name rather than a partial one. - A package with no route files of its own no longer flags every
route()call as unknown. A Laravel package registers its route names from the host application rather than its ownroutes/files, so analysing the package alone read the result as "no route names exist" and flagged everyroute('…')call inside the package. The check now asks whether the project itself registered any route, rather than whether the route table is empty: the installed packages (Horizon, Livewire, Sanctum, and the rest) each register routes of their own, so the table is never empty and the earlier guard never fired. Route names still resolve, complete, and hover against everything discovered, packages included. - Config keys from a file we cannot see are no longer reported as unknown. A shared Laravel library ships no
config/directory of its own and reads keys the host application supplies, but the check compared them against every key discovered anywhere, including the hundreds that come fromvendor/, so eachconfig('acme.driver')was flagged. A key is now judged only when the config file its first segment names is one we actually read, so an unreachable file stays unjudged while a typo in a real one (config('app.tzimezone')) is still caught. Config::set()no longer reports the key it defines as unknown.Config::set('filesystems.disks.ondemand', […])andconfig()->set(…)write a key rather than read one, which is exactly what a test that seeds a disk or overrides a value does, yet the written key was checked against the set of declared keys and flagged. Writes are now recognized as declarations; hover, go-to-definition, and find-references on the key still work at the write site.- View names written with
/separators resolve. 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 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. analyzereports Laravel string key errors again. A debug build ofphpantom_lsp analyzefound none of the route, config, view, translation, command, or morph alias problems the editor reports, so a typo likeArtisan::call('does:not-exist')came back as[OK] No errors. The analyze run timed each check separately from its own list of checks, and that list had fallen behind the one the editor uses. Both now run the same list, so a check added for the editor is reported byanalyzetoo.- 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 route and 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. - Override completion keeps
readonlyon a redeclared property. At the class-body root, a parent'spublic readonly string $onNamewas offered with the inserted declarationpublic string $onName;, which PHP rejects with "Cannot redeclare readonly property". The generated declaration now carries the modifier through, so accepting the suggestion produces code that compiles. Promoted constructor properties count too: apublic readonly string $labelpromoted in the parent's constructor is redeclared as readonly as well. - Override completion no longer offers
finalinherited methods. A parent'sfinal public function onLock()was suggested as an override candidate, and accepting it inserted a declaration PHP rejects outright ("Cannot override final method Base::onLock()"). Both entry points are fixed: the name-only path afterfunction, and the class-body root, where the inserted snippet is a full declaration. Afinalmethod reached through a trait the class uses directly is still offered, since the class's own declaration legitimately wins over the trait's copy. Calling afinalmethod is unaffected, so it still appears in ordinary member completion. - 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. analyzeno longer flags the framework's own Artisan commands as unknown. The command and macro indexes were built only from the files the headless pipeline parses, which is the project's own source, so every command a vendor package ships (queue:work,migrate, …) read as unknown and a macro registered by a vendor service provider could produce a false-positive unknown member. Both indexes are now built from the whole class index the way the editor already builds them, soanalyzeand the editor agree.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.- A
rules()method supplied by a trait is now read. AFormRequestthat gets itsrules()from a trait rather than declaring one offered no request-input keys and novalidated()shape, because only the class body and the parent chain were searched. The traits a request uses are now followed as well, inuseorder and through a trait's own traits, and go-to-definition on a key lands on the trait that declares 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. array_merge(parent::rules(), […])keeps the parent's keys. AFormRequestthat composes its parent's rules only offered the keys it added itself, so completion listed an incomplete set, go-to-definition could not reach an inherited key, and thevalidated()shape was abandoned. The ancestor's rules are now followed and merged at the position PHP'sarray_mergewould put them, with a later key overriding an earlier one of the same name, and an inherited key navigates to the parent request that declares it.- A
validate()call in a branch contributes its keys. With onevalidate()call in each arm of anif/else, only the last one written described the request afterwards, so the other arm's keys were missing from completion and from thevalidated()shape. Whichever arm ran, the request carries its keys, so a call the cursor could have skipped is now merged with what preceded it, while a call that definitely ran still replaces it the wayvalidated()returns only the last validation's data. - An
excluderule no longer leaves its field in thevalidated()array. Laravel validates anexcludefield and then drops it, so the key is never in the result; it used to appear in the shape anyway. The conditional family (exclude_if,exclude_unless,exclude_with,exclude_without) now makes the key optional instead of guaranteed. - 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(). - Base relationship builder methods no longer appear as model properties.
hasMany,belongsTo,morphOneand the other relationship builder methods inherited fromHasRelationshipswere incorrectly synthesized as virtual properties (andhas_many_count,belongs_to_count, etc. as count properties) on every Eloquent model. Only user-defined relationship methods now produce virtual properties. @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.- An explicit
@propertytag overrides the database-derived column type. A model attribute documented as@property Carbon $published_at— on the model itself or on a trait it uses — resolved to the raw column type from the schema or migrations (string|nullfor a timestamp), so chaining date methods on it reported "Cannot access method on type 'string'". The tag is what the author declared the attribute to be, so it now takes precedence over types inferred from the database, while types produced by real PHP code ($casts, accessors, relationship methods) still win. - 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.
- Linked editing no longer types into unrelated parts of the file. Adding a line above a variable (for example inserting a
/** */docblock) and then typing could insert the typed characters at two arbitrary places further down, such as in the middle of a method name and in front of a trailing comment. PHPantom is re-reading the file in the background while you type, and linked editing was handing the editor positions measured against the previous version of the file, which the editor then edited on trust. Linked editing now verifies that the positions it reports still point at the variable, and offers nothing at all if they do not, so a keystroke can never land somewhere unexpected. It also becomes available in Blade templates, where the reported positions are checked against the template itself rather than the generated PHP. - 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 works on Cursor, VSCodium, Neovim, and other editors. Clicking a code lens annotation (e.g. "overrides Parent::method") now uses the standard
window/showDocumentLSP request instead of editor-specific commands (vscode.open,editor.action.showReferences) that only worked on VS Code. 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. - 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 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.
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.