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

  • $this in 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 $this in 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 $this carries 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 $this is 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 and View::composer('partials.*', …) puts one in the scope of the views it targets, but neither is written in a template or passed by any view() call, so a template that read one reported it undefined. PHPantom now reads both from your service providers, whether they are written against the View facade 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 whose compose() body does, and the View::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 own Str::is() does. These sit below the template's own declarations and the variables Blade injects into a component body, so a shared variable named slot never 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.php configures, the view directories packages register, and the namespaces class-based and Livewire components live in, including the ones a service provider registers with Blade::componentNamespace(…) and a custom livewire.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 by App\View\Components\Card\Card, <livewire:posts> by App\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 the Filesystem contract, and PHPantom resolves them to what the disks in config/filesystems.php are 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 the Storage::extend('name', function (…) { … }) registration in your service providers, so the disk it backs resolves to whatever that closure builds. The documented registration shape returns a FilesystemAdapter, 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(…)), the App\View\Components convention, 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 @aware declarations 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()), and alias(Concrete::class, 'key') are now indexed, so app()->make('sentry'), app('sentry'), and resolve('sentry') resolve to the bound class the same way a ::class argument 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 own TranslationServiceProvider, 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-signature docblock is the template's contract; without the marker the first docblock before any template code serves as one. Below that, @props and @aware supply 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 $componentName alongside $attributes and $slot. Types inferred from view() 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 @props written inside a comment, a @verbatim block, 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 @var declarations 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, including View::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 @var annotations still take precedence: a template that documents its own contract is left untouched. Closes #296.
  • Analyze verbosity flags. phpantom_lsp analyze now supports PHPStan-style --debug and -v/-vv/-vvv flags. --debug prints 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. -v adds per-file durations and a phase timing summary, -vv adds worker ids and parse-phase tracing, and -vvv adds 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's config/*.php files. 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 from vendor/laravel/framework/config/ fill in any keys the project's own config file leaves unset, so a partially published config/app.php still 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.toml now supports [semantic_tokens] mode = "contextual" | "full" | "off". The default contextual mode emits only context-sensitive highlighting that complements editor syntax grammars, while full keeps the previous broad semantic-token stream and off disables semantic tokens. Contributed by @calebdw.
  • @phpstan-ignore identifiers are highlighted and completed. PHPStan ignore comments now highlight the @phpstan-ignore tag 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 o offers public function onChange(callable $callback): self from 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 function in a class body (for example protected 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 example protected $tit) and constants after const, 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+ (from composer.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 function or $ 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/schema by default, reads config/database.php for 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, Laravel Connection/Table attributes, 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/migrations directory (including nested modules like modules/billing/database/migrations), applied in global filename order, and support named and anonymous migration classes, $connection properties, Schema::connection() calls, Blueprint::after() nested closures, virtualAs/storedAs generated 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] enabled and paths in .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(), and loadRoutesFrom() 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 fluent Route::…->group(base_path('…')) API, or a plain require/include inside a Route::group([…], function () { … }) body. The name and URI prefixes of the enclosing group carry into the included file, and registrations written inside an if block, 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, and route('…') 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 from routes/*.php (including group prefixes and Route::group([], __DIR__ . '/sub.php') file includes), Route::resource() and Route::apiResource() generate conventional named routes (index, create, store, show, edit, update, destroy) respecting ->only() and ->except() modifiers, config keys from config/*.php array declarations, view names from resources/views/ file paths, and translation keys from lang/ 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 from config/database.php). Facade methods like Auth::guard(), DB::connection(), Cache::store(), Log::channel(), Storage::disk(), and the auth() 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 covers Artisan::call(), Artisan::queue(), Schedule::command(), and $this->call() / $this->callSilently() inside a command. The $signature grammar ({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 of Artisan::call('cmd', [...]) completes the target command's argument and --option keys. 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, in to_route(), signedRoute(), and temporarySignedRoute() 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() and Route::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', …) gives photos, photos/create, and photos/{photo} — so route('photos.show', ['photo' => $photo]) completes its parameter like any other route. A nested name singularizes each parent segment (photos.comments becomes photos/{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 as only() and except(), safe()->only([...]), and array access $request['key']. The rules come from the rules() method of the FormRequest type-hinted in the enclosing method (following array_merge() and the parent chain), or from a validate() / 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 like items.*.id complete 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 return array and 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 a string, $data['views'] is an int, 'nullable' adds null, a field that is neither required nor nullable becomes an optional key, 'items.*.id' produces list<array{id: int}>, and an 'image' rule gives you a real UploadedFile to 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, and safe()->only([...]) / except([...]), which narrow the same shape. When the key set cannot be read in full the shape is abandoned for plain array rather 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, and only($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}() and for{Relationship}() methods that Laravel resolves through Factory::__call(), one per relationship on the associated model, plus trashed() when the model uses SoftDeletes. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so Post::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 $pivot attribute on many-to-many related models. Models that are the target of a belongsToMany/morphToMany relationship now expose a $pivot property, so accessing the intermediate row (e.g. $user->roles->first()->pivot) completes, hovers, and resolves. The pivot type is taken from the relationship's TPivotModel generic (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 declared pivot property 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. A Str::mixin(new StrMixin()) or Collection::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 a Target::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's mixin() 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 Carbon macro() calls (e.g. CarbonImmutable::macro('name', fn () => ...)) also resolve through the same pipeline as Laravel's Macroable. Contributed by @calebdw.
  • @phpstan-require-implements contributes to trait $this resolution. Traits annotated with @phpstan-require-implements InterfaceName now resolve $this against the required interface inside trait methods, matching the existing @phpstan-require-extends behavior 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. The model-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 as model-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, …]) or Relation::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 $types argument of the whereHasMorph() family (including the '*' wildcard and class-name spellings, which are left alone). Laravel's list shorthand Relation::morphMap([Post::class, …]) is understood too, keyed by each model's table name the way the framework derives it. When the project calls enforceMorphMap() (or requireMorphMap()) 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) or workspace-external = false (external tools) in .phpantom.toml.
  • Higher-order collection proxies. $users->map->email is 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 — map collects the member, filter and each keep the collection, first gives you one nullable item, contains a bool, sum a 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 returns static stays 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 field mixed, 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 (string for a string-backed enum, int for 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 like Rule::enum(Role::class)->only([…]), and its class name is resolved against the imports of the file that declares the rules, so a FormRequest's own use statements are what count. A pure enum has no raw scalar form, so those fields stay mixed rather 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 string parameter, every ?Carbon property, every Collection<User> return) are stored once and shared instead of duplicated at each occurrence. Comparing two types is now a quick reference check rather than a walk over their structure, so analysis is slightly faster too. On large Laravel projects this meaningfully cuts both peak memory and live heap size, with no change to what PHPantom resolves.
  • Lower memory use when resolving class hierarchies. Resolving a class no longer copies every inherited or synthesized method, property, and constant onto it. Members a merge doesn't actually change are shared with their source across the whole workspace, and members it does produce (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 var detail 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-sealed and @template-extends are 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 @throws completion 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 @template bound resolves like its single-line form, and trailing prose no longer leaks into a @phpstan-type alias definition or a @method parameter 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.
  • @method and @property tags are parsed once per class instead of on every resolution. The magic members a class declares in its docblock are now parsed when the file is read and reused from then on, instead of being re-parsed from the raw comment text every time the class (or anything that inherits or mixes it in) is resolved. Whole-project analysis of a large Laravel codebase runs a few percent faster and uses slightly less memory, with identical results.
  • Faster diagnostics on large projects. Several diagnostic checks (by-reference parameter detection, the Stringable-to-string acceptance check, and model-property<Model> literal validation) now reuse a class's already-resolved inheritance instead of re-merging traits, parent classes, and generics on every call. As a side effect these checks now also see interface-declared members (e.g. a __toString declared 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 analyze and fix CLI subcommands no longer build the cross-file reference index. That index only serves Find References, Rename, and reference-count inlay hints, none of which the CLI subcommands query, so skipping it removes wasted work from whole-project analyze and fix runs. 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 .phar is 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.json a second time and prefix-matching every class's file path against the package list on a single thread. The origin is now attached to a class the moment it is discovered during the already-parallel vendor scan, the same way it already worked for functions and constants, 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 an assert() or @phpstan-assert/@psalm-assert call, 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 $comment truncated the new line into $author = $->createdByUser;, mirroring a deletion the user never intended to repeat. Use textDocument/rename (explicit, cross-file, previewable, one undo step) or your editor's multi-cursor instead.

Fixed

  • Storage::disk() and friends resolve to the concrete adapter instead of the bare contract. FilesystemManager::drive()/disk()/cloud()/build(), and the Storage facade's matching @method tags, only ever declared the abstract Filesystem/Cloud contract, even though every driver the framework ships builds a concrete FilesystemAdapter. Adapter-only members like assertExists() in a test's Storage::fake()/disk() chain, or download() on a controller's configured disk, were reported as missing. config/filesystems.php is 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 custom Storage::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')] alongside protected $signature = 'x:from-property' was indexed as x: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 order Command::__construct() applies at runtime. Contributed by @AJenbo.
  • Commands whose class name does not end in Command are found. A package that names its command classes after the action alone and groups them in a Commands/ directory, as monicahq/laravel-cloudflare does with src/Commands/Reload.php, contributed nothing to the index, so cloudflare:reload was flagged as an unknown command even though Artisan lists it. Classes in a Console/, Commands/, or Command/ 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 a global declaration plus top-level call arguments that require variable resolution could re-enter the same scope construction, restarting the full top-level walk from every nested query until the request hung. The walk is now guarded against re-entry on the same file content, and a query-level guard prevents the exact same variable resolution from re-entering itself through indirect call paths. Closes #327.
  • Storage::disk() and friends resolve to the concrete adapter instead of the bare contract. FilesystemManager::drive()/disk()/cloud()/build(), and the Storage facade's matching @method tags, only ever declared the abstract Filesystem/Cloud contract, even though every driver the framework ships builds a concrete FilesystemAdapter. Adapter-only members like assertExists() in a test's Storage::fake()/disk() chain, or download() on a controller's configured disk, were reported as missing. config/filesystems.php is 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 custom Storage::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')] alongside protected $signature = 'x:from-property' was indexed as x: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 order Command::__construct() applies at runtime.
  • Commands whose class name does not end in Command are found. A package that names its command classes after the action alone and groups them in a Commands/ directory, as monicahq/laravel-cloudflare does with src/Commands/Reload.php, contributed nothing to the index, so cloudflare:reload was flagged as an unknown command even though Artisan lists it. Classes in a Console/, Commands/, or Command/ 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 @props just to exist. Laravel merges every attribute written on an <x-…> tag into the component's own variable scope; @props only supplies defaults and removes the key from $attributes. PHPantom only created the variable when @props named it, so <x-brand.boxes :hairAnalysis="$model->hairAnalysis" /> read as $hairAnalysis inside boxes.blade.php reported an undefined variable unless the component redundantly declared it. The variables each <x-…> call site passes are now inferred the same way view() call sites already are: a bound attribute's expression is typed from the caller, a plain string attribute is typed string, and a hyphenated attribute name (hair-analysis) is read under the camelCase name Blade actually exposes it as. @props/@aware still win over the inferred type for the same name.
  • A @var whose type is a closure signature binds the right variable. /** @var \Closure(\App\Models\User $user): string $callback */ read $user, the closure's own parameter name, as the annotated variable, leaving $callback untyped and adding a bogus $user to scope. The same shape decides which names a Blade template's signature docblock declares, so a component contract that documented a closure prop lost that prop's name entirely. The scan now tracks paren and angle-bracket depth while walking the type, so it stops at the $name that actually follows the type rather than the first $ it sees.
  • A dotted container key no longer resolves to a class named after its first segment. app('demo.bakery') could resolve to an unrelated project class named Demo instead of the class the container key was actually bound to. A container key is never a valid PHP class name, but it was normalized through the same parser used for type hints, which stops reading an identifier at the first character it cannot contain, silently truncating demo.bakery down to demo. A key containing a character no PHP identifier can hold is now looked up directly against Laravel's own alias tables instead.
  • A 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 $signature property, so referencing such a command no longer reports invalid_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 $case as a bare array, so $case[0] had no type and member access on it was reported as unverifiable. Assigning the inner call to a variable first worked, and so did passing a call that needed no element-type inference. The element type the array functions compute for an inline call (iterator_to_array(), array_values(), array_filter(), ...) was being overwritten by the bare array the stubs declare, so nothing was left for the callback's parameter to narrow to. The computed type now survives, which also gives a more precise type to anything else reading these calls inline.
  • A container call through the App facade resolves when it is chained directly. $repo = App::make(EventRepository::class); followed by $repo->getActiveEvents() resolved, but the one-line App::make(EventRepository::class)->getActiveEvents() did not, and neither did App::makeWith(...)->run(). A facade forwards its static calls to a container class, and only the assignment path knew to look past the facade's own @method tag (which flattens the container's argument-dependent return to object|mixed) to the concrete class that actually types the call. The chain resolver now makes the same jump, so both spellings resolve, matching the app() helper.
  • A @props list no longer overrides the types a template declares. A component that declared its contract in a docblock and then listed the same names in @props kept the declared type for the first name only; every later one was bound to null, so passing it anywhere reported "expects …|string, got null". @props now 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 as null. 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 @props is 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.
  • analyze reports the same diagnostics on every run. Two runs over an unchanged directory could differ by dozens of messages, which made it impossible to tell a real regression from noise when comparing two builds over a corpus. Three things let a file's result depend on what the parallel workers happened to reach first. Bundled stubs were the only files with no protection against two workers parsing them at once, and the worker that finished second took the re-parse path, discarding every already-resolved class that depended on the stub. An interface was merged into an implementing class in full when it was already cached and as a weaker approximation when it was not. And a class declared in more than one file, as Carbon's DatePeriodBase and Symfony's polyfilled RoundingMode are, resolved to whichever copy was parsed last, so a name could pick up the legacy variant of a class that also ships a modern one. Results no longer depend on the worker count either, so machines with different core counts agree.
  • 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's Collection::wrap() declares) already bound the template one level deeper for a container argument typed array<string>, but an array literal argument resolved to a bare array with no element type before it ever reached that check, so nothing useful bound and Wrapper::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 TValue with @return static<array-key, TValue>, bound its element type when the result went through a variable but lost it when the call was chained straight on: the static path read the factory's declared return type without applying the bindings it had just computed, and then flattened static<…> to a bare class name, dropping the arguments with it. Both now happen the way they already did for an instance method, so Wrapper::make(names())->push([1]) reports the same argument mismatch that the two-line form does.
  • A standalone @var docblock narrows a call inside the same echo, if, or other non-expression statement. A /** @var Collection<string, Loaf> $byName */ written on its own line, immediately followed by a statement other than a bare expression (an echo, an if, a return, ...), correctly typed the variable everywhere after that statement but not within an expression inside the statement itself: a diagnostic scope snapshot taken right before the docblock was applied never got refreshed, so a call reached through the annotated variable saw its bare class instead of the generic arguments the annotation gave it and fell back to the class's declared template bound. Every Blade {{ $byName->get(...) }} compiles to exactly this shape (echo e( $byName->get(...) );), which is how this most commonly surfaced.
  • A callback body that is a call binds the template it returns. An unannotated callback takes its return type from its body, and a body that was a call had none to give: only classes came back from that step, so ->keyBy(fn (Review $r) => $r->getRating()) left the key template unbound and a later $byRating->get(1) was reported as expecting array-key|\UnitEnum|null instead of int|null. A call body now resolves to whatever the callee returns, scalars included, the same way a property read or a new expression already did.
  • A union parameter hint binds through the alternative the argument matches. A parameter that accepts either an element or a container of elements, as Laravel's Collection::wrap() does with @param iterable<array-key, TWrapValue>|TWrapValue $value, always bound the template to the whole argument, so wrapping a string[] gave a collection of string[] rather than of string. The alternatives are now tried against the argument's actual shape, and the bare one, which matches anything, is only used when none of the others fit. Key and value positions line up across container shapes too, so a list<string> argument binds a TKey/TValue pair correctly instead of leaving both at their declared bounds.
  • A re-keying callback rebinds the key type of the collection it returns. Collection::keyBy() and every other method whose return type is bound to a callback's return type (@param callable(): TNewKey) missed that binding in two common cases, and fell back to the template's declared bound, so a later $keyed->get('slug') was reported as expecting int|null or array-key|null. A callback written static fn (…) => … or static function (…) { … } is now read as the closure literal it is, rather than being skipped over the modifier. And a callback with no return-type annotation now keeps its body through call-chain resolution, so ->keyBy(fn ($row) => $row->slug)->get('slug') binds the key type from the body the same way an annotated callback binds it from the annotation.
  • A static call through a string-typed subject is no longer reported as scalar access. $string::method() is valid PHP: the string is read as a class name at runtime. PHPantom treated it the same as $string->method(), which really is a crash, and reported Cannot access method 'method' on type 'string' for both. A :: access on a subject whose only possible type is string (a bare variable, a property, a return value) is now left unresolved instead, since PHPantom cannot verify the class name a running program would supply. A subject typed class-string<T> goes further: :: now resolves against T itself, so $job->class_name::dispatch() completes and type-checks against the job class the property names. Other scalars (int, bool, …) can never name a class, so $intVar::method() still reports the scalar-access error.
  • A class in a file's global namespace { } block keeps its global name. A file that pairs an anonymous namespace { ... } block with a named one, as the bundled PDO stub does, labelled the classes in the global block with the sibling namespace. PDO was reported as Pdo\PDO, so hover, go-to-definition, and diagnostics all named the wrong class ('Pdo\PDO::sqliteCreateFunction' is deprecated). Each block's classes now carry the namespace they were actually declared in.
  • An array function keeps its element type when the call is used inline. $rows = iterator_to_array($it); $rows[0]->name resolved, but writing the same thing in one go as iterator_to_array($it)[0]->name did not: the element-type rules for the array-producing standard library functions only ran when the call was assigned to a variable, and every other position fell back to the bare array the stub declares. Those rules now apply wherever the call appears, so indexing straight into array_map(), array_filter(), or iterator_to_array() resolves, and so does nesting one inside another. A conditional or generic return type resolved from the call's arguments is now reported alongside the classes it names, so a call that resolves to an array shape or a list of scalars is no longer flattened back to its declared type.
  • A closure parameter declared as plain array narrows to what the call site passes. A callback handed to array_map(), array_filter(), or any method whose parameter is typed callable(T) may declare its own parameter with the widest hint PHP has a keyword for, since PHP itself cannot express the element type. PHPantom took that hint at face value and threw away the element type it had already worked out, so static fn (array $case) => $case[0]->name over a array<array{DiscountType, string}> left $case[0] with no type at all and every member reached through it was reported as unverifiable. A bare array or iterable hint now yields to the element type the call site knows, while a hint that says anything the call site does not (a class name, a union, an element type of its own) still wins.
  • App::make(), App::makeWith(), and App::resolve() resolve a class-string argument to that class. app(CurrencyHelper::class) and app()->make(CurrencyHelper::class) already resolved to the concrete class, but the App facade did not: App::make(CurrencyHelper::class)->format() reported the member as unresolvable. Two issues combined to hide the underlying container's argument-dependent return type: the facade's own @method docblock tag flattens it to a bare object|mixed, and the container-binding key App::getFacadeAccessor() returns ('app') is registered against self::class in the framework's own alias table, which PHPantom discarded as unresolvable. App::make()/makeWith()/resolve() now fall through to the real Container/Application declaration whenever the facade's own signature does not narrow the return, and self::class/static::class entries in the core container alias table resolve to the class whose source is being parsed.
  • An assignment through a by-reference closure capture is no longer lost. A closure that writes to a variable captured with use (&$var) updates that variable whenever it runs, but PHPantom only credited the write when it could prove the closure ran before the call returned, and it could rarely prove that: a chained receiver such as Db::connection()->transaction(…), a closure stored in a variable and called later, or an unresolvable callee all left the outer variable at its old type. A $var !== null check then appeared to narrow null to nothing, and passing the variable on was reported as a type mismatch. The types a by-ref capture assigns now widen the outer variable even when the invocation cannot be proven, matching how PHPStan treats such captures, and a provably immediate invocation (an immediately-invoked function expression, or a callable parameter considered immediately invoked) still replaces the type outright. Closes #329.
  • A check stored in a variable still narrows. $isHtml = $raw instanceof HtmlString; carries the check, so if ($isHtml), $isHtml ? … : …, and a !$isHtml guard clause should all narrow $raw the way the original expression does. PHPantom only narrowed the expression written in place, so every member reached through the subject behind the boolean was reported as unresolvable. The boolean now stands for the check wherever it is tested, in diagnostics, completion, hover, and go-to-definition alike, and stops doing so once the boolean or its subject is written to.
  • 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 rebinds translator or translation.loader to 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 … @endphp block 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 == 1 only evaluates its right-hand side once $x is known to exist, and !isset($x) || $x == 1 likewise, but PHPantom still reported $x as undefined in both. This shape is common in Blade templates (@if (isset($isOutlet) && $isOutlet == 1)), where it produced several false positives per file. A read anywhere later in the same &&/|| chain as a guarding isset()/!isset() is no longer flagged; a plain if (isset($x)) { ... } still leaves $x undefined in the body, since isset() alone does not define it.
  • A @method tag no longer overrides a method that really exists. PHP only reaches __call() when no accessible method is found, so a @method tag naming something a parent or trait already declares never takes effect at runtime. PHPantom honoured the tag anyway, replacing the real signature with whatever the tag said. A test base class that documents @method MockInterface mock(string $abstract) alongside Laravel's inherited mock() was enough to throw away the framework's precise type, so $this->mock(Client::class) came back as a bare Mockery\MockInterface and returning it from a helper declared Client&MockInterface was reported as a type error. The real method now wins, and a @method tag applies only where no such method exists.
  • A standalone @var block keeps its variable in scope for the rest of the body. An annotation that stands on its own, like the /** @var App\ViewModels\ShowViewModel $model */ a Blade template opens with, was only picked up when it sat directly above an assignment. Anywhere else, a // short comment written under it or an if block written above the use site was enough to lose it, and every member reached through the variable from that point on was reported as unverifiable. Such a block now declares the variable the same way an annotated assignment does, so it survives intervening comments and any number of sibling blocks, in Blade templates and in ordinary PHP alike.
  • Imports written inside a Blade template are honoured. Laravel compiles a template's @php and <?php regions into the top level of the generated view file, so a use App\Helpers\CurrencyHelper; written in one imports for the whole template. PHPantom never registered those imports, so CurrencyHelper::formatPrice(…) was flagged and, worse, anything assigned from the short name was left untyped, which took every property, loop variable, and @var derived from it down with it: one view produced 17 diagnostics from a single unrecognised import. The same imports now populate the template's import map whichever way they are written, including the @use('App\Models\Post') directive, which was hoisted to a point in the generated PHP where it no longer applied to the template body. As a result an import nothing in the template references is now correctly reported as unused.
  • $attributes and $slot are recognized in Blade components. Laravel puts both in scope of every component view, but PHPantom knew about neither, so a component template reported Undefined variable '$attributes' on the tag it merges its classes into and could not resolve anything reached through either name. A template that lives in a components directory, or that uses @props or @aware, now starts with $attributes typed as Illuminate\View\ComponentAttributeBag and $slot as Illuminate\View\ComponentSlot, so $attributes->merge([...]) and $slot->isEmpty() resolve, complete, and hover. An ordinary view is unchanged: $slot there is still undefined, because Laravel does not pass it one.
  • @props declares 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 as Undefined variable. Each key is now declared as a local variable assigned its default value (or null for a defaultless prop, e.g. @props(['visible'])), and the array can span multiple lines as it usually does. Attributes with no @props declaration 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 to trim/ltrim/rtrim, strtolower/strtoupper, ucfirst, str_replace, implode, sprintf, and preg_replace when every argument is already statically known, so route('events.xmas.gift-sets') and similar names resolve instead of being flagged invalid_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.
  • $this inside 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 $this inside Route::macro('auth', function () { $this->get(…) }) is the router, not the service provider the registration is written in. Completion already resolved this correctly, but every other consumer built its own resolution context without the same lookup, so unknown-member and deprecated-usage diagnostics flagged every member call on $this in a macro body, and hover and go-to-definition fell back to the enclosing class. The lookup now lives on Backend and is shared by every consumer.
  • Provider resource paths behind a local variable are now resolved. mergeConfigFrom(), loadViewsFrom(), loadTranslationsFrom(), and loadRoutesFrom() 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 reported Unknown 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); or require $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/ui ships Route::auth() as a macro whose body registers login, logout, register, and the password.* 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 calling Route::auth() contributed none of those names and every route('password.update') was reported as unknown. A call to a router macro is now expanded against its registered body, whether it was registered with Route::macro() or through Route::mixin(), and whether it is called on the facade, on the router itself from inside another macro, or through Laravel's own Auth::routes(), which forwards to the router's auth macro. The name and URI prefixes in force at the call site carry into the macro's routes, so Route::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 a foreach over 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 own routes/ files, so analysing the package alone read the result as "no route names exist" and flagged every route('…') 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 from vendor/, so each config('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', […]) and config()->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 accepts view('redirects/create') and @include('partials/lux-popups/modals/_card') the same as the dotted spelling, and tolerates a leading slash, but PHPantom recognized dotted names only and reported the rest as unknown views. A view name is now canonicalized wherever it appears, so both spellings hover, navigate, complete, and feed Blade call-site inference as the one template they name.
  • A comment no longer hides a request field's receiver, including through the safe() hop. $request /* the request */ ->input('|') and $request->safe() /* validated */ ->only(['|']) offered no field completion, because the receiver was recovered by a backwards text walk that stops at a comment's closing */. It now comes from the same forward scan used elsewhere, which sees past the comment and, for the safe() hop, through the closed call to the request it narrows.
  • A comment before the arrow or double colon no longer hides a string-argument call's receiver. $q /* the query */ ->where('a') and User /* the model */ ::with('a') offered no completion inside the string, because the receiver was recovered by scanning raw text backwards from the operator, which stops at the first byte that cannot continue an identifier — the closing */ of the comment. The receiver's boundary now comes from the same forward reading of the file that already finds the call and its argument list, so it sees past the comment to the receiver behind it.
  • auth()->user() and Auth::user() resolve to the configured model again. The guard-aware resolution covered $request->user() and the guard-argument spellings (auth('admin')->user()), but the two most common entry points slipped through. A no-argument auth() returns the Factory contract, which declares no user() at all, so every member call on it reported "Method 'user' not found"; the contract now carries the concrete AuthManager the container binds to it, whose @mixin Guard forwards user() and friends to the default guard. And the Auth facade declares user() only as a @method docblock tag, which the model refinement never touched, so it stayed at the bare Authenticatable contract; the tag's return type is now refined to the default guard's configured model like the real methods are. Completion, hover, go-to-definition, and diagnostics on auth()->user()->email and Auth::user()->email all see the concrete model. Closes #298.
  • A comment no longer displaces the call a string argument belongs to. The literal the cursor is typing in is found by reading the file forward, but the call around it was still recovered by scanning backwards over raw text, where a comment reads as code. A bracket in one unbalanced the search for the call's opening parenthesis and the completion dropped out altogether ($query->where('a' /* ) */, 'b')), and a comma in one counted as an argument separator, so what was offered were the suggestions for a later parameter ($query->where('a' /* , */, 'b') was read as the third argument). The call, the argument index, and the method name now all come from the same forward reading, so Eloquent columns and relations, request input keys, and model-property parameters complete in those positions, a comment between the method and its argument list ($query->orderBy /* asc */ ('…')) no longer hides the call, and neither does an argument list more than a couple of thousand characters long, which the backwards scan gave up on.
  • Generated PHPDoc types now use the file's use imports instead of the fully qualified name. @param, @return, and inline @var completion (and the "Update Docblock to Match Signature" and "Extract Function/Method" code actions) enrich a type with its @template parameters, e.g. Collection<TKey, TValue>, but always spelled the class name out in full even when the file already imports it: App\Collection<TKey, TValue> instead of Collection<TKey, TValue>. Generated types are now shortened through the same use-map and namespace lookup the class-name completion path already used.
  • analyze reports Laravel string key errors again. A debug build of phpantom_lsp analyze found none of the route, config, view, translation, command, or morph alias problems the editor reports, so a typo like Artisan::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 by analyze too.
  • A comment or a line break no longer hides the string the cursor is typing in. The literal a string completion belongs to was found by reading the cursor's own line for quotes without regard for comments, so an apostrophe in a note earlier on that line paired up with the real opening quote and left the cursor looking like it sat outside a string. Artisan::call('app:sync', [ /* don't ( */ ']) offered no parameter keys, and neither did anything else that completes inside a literal. Anchoring to the line was also why a call broken over lines ($request->input( and the key on the next line) offered nothing. The literal now comes from the same forward reading of the file the surrounding code already uses, so command and route parameters, request input keys, Eloquent columns, and Laravel route, config, view, and translation keys all complete in those positions.
  • A conditional buried in a generic return type is decided at the call site. $collection->groupBy('key') returns Collection<($groupBy is array|string ? array-key : TGroupKey), …>, and the conditional travelled on as-is into the key type of the result, so the next call in the chain compared its argument against a type expression no value can match: Argument 1 ($key) expects $groupBy is array|string ? array-key : array-key, got 'bucket'. The condition is now decided against the arguments of the call it belongs to, wherever it sits inside the return type. A conditional that genuinely cannot be decided is compared as the union of its branches, which is what the value satisfies either way, and is reported that way too.
  • A generic class is no longer rejected by a parameter typed with that same class. new Decimal('0.00'), where Decimal declares a @template parameter that the constructor does not bind, resolves its template arguments to their declared bounds and became Decimal<bool>. That type carried only the class's short name, which nothing outside its own namespace can resolve, so passing the value to a Decimal $amount parameter reported expects Acme\Decimal\Decimal, got Decimal<bool>. Such a type now carries the fully qualified name and matches the parameter, and a generic type whose name the project genuinely cannot load no longer produces a mismatch at all.
  • A comment no longer stops route and command parameter keys from completing. The keys of route('users.show', ['user' => 1]) and Artisan::call('app:sync', ['user' => 1]) were found by reading the text before the cursor from right to left, which cannot tell a comment from code: a note holding a bracket, a parenthesis, or an apostrophe between the call and the key unbalanced the reading and no keys were offered. That text is now read in the direction PHP reads it, so comments (//, #, /* … */), heredoc bodies, and the HTML around a <?php block are all seen for what they are.
  • Type casts in ternary and conditional branches are now resolved. $x = isset($a) ? (int) $a : null inferred only null instead of int|null, because a cast carried its type only when it was the entire right-hand side of an assignment. This produced false-positive argument type mismatches once the variable reached a position that required the non-null branch, such as $x === null || !take_not_null($x). The unary ! and ~ operators were affected the same way, and (object) in a branch now yields the same object shape it does in a direct assignment.
  • A union merged out of a one-sided if is listed in source order. A variable assigned before an if and reassigned inside it hovered as the in-branch type first, so $x = new Foo(); if (…) { $x = new Bar(); } showed Bar above Foo. An if/else where both branches assign has always rendered in source order, so the two shapes disagreed on which type reads as the headline. The pre-branch type now comes first in both, and go-to-definition on a member both types share follows the same order.
  • Override completion keeps readonly on a redeclared property. At the class-body root, a parent's public readonly string $onName was offered with the inserted declaration public 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: a public readonly string $label promoted in the parent's constructor is redeclared as readonly as well.
  • Override completion no longer offers final inherited methods. A parent's final 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 after function, and the class-body root, where the inserted snippet is a full declaration. A final method 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 a final method is unaffected, so it still appears in ordinary member completion.
  • Nested @param-closure-this closures resolve $this to the innermost binding. With a closure passed to a call inside another such closure (a Route::group() holding a nested group, a macro registered inside another registration), $this in the inner body kept resolving to the outer call's declared type, or fell back to the lexically enclosing class. The innermost @param-closure-this now wins, at any nesting depth, and the inner call's own receiver is resolved through the binding that surrounds it, so completion, hover, and go-to-definition all see the right class. self:: and static:: inside such a closure follow the same binding.
  • @param-closure-this is found even when the outer closure is not itself a call argument. When a closure holding the call site was assigned to a variable, stored in an array, or returned from a function rather than passed directly as an argument, $this inside a call nested further in fell back to the lexically enclosing class instead of the @param-closure-this type. Such a closure does not rebind $this itself, but the call inside it is now still found.
  • A plain function body resolves class names against the file's namespace. PHP looks up an unqualified new Foo, Foo::bar(), or Foo::CONST in the current namespace before the global one, and PHPantom did that only when the reference sat inside a class. From a plain function, a closure at file scope, or top-level code it fell back to the global namespace, so Aborter::fail() inside namespace App resolved to \Aborter whenever a global class of that short name existed. Hover, completion, go-to-definition, and diagnostics now all pick the same class PHP would.
  • A guard clause written with the alternative if: … endif; syntax now narrows. if (!$x instanceof Foo): return; endif; left $x unnarrowed afterward, unlike the identical guard written with braces. The colon-delimited form now tracks which branch unconditionally exits the same way the brace form does, so a return, throw, or never-returning call inside it narrows the type that follows.
  • "Extract function" no longer breaks by-reference writes. A selection that assigns to a variable bound by reference (a &$param, a foreach (… as &$item) value, a use (&$total) capture, or the target of a $ref = &$value) was extracted like any other code. The new function received a copy, so the mutation the caller was relying on silently disappeared. Those selections are now left alone. Reading a by-reference variable is still extracted as before, and so is a write that happens before the reference is taken.
  • Calling a function or method that returns never is now recognized as an unconditional exit. Guard clauses like if (!$x instanceof Foo) { abort(); } where abort() is declared with return type never now narrow the type after the if block, and an assignment made in the branch is treated as the dead code it is, exactly like return, throw, exit, or die. Functions, static calls, and method calls all count, whether the method is declared on the class, inherited, or supplied by a trait, and local variables narrow the same way properties do.
  • A fluent chain through a union return type no longer hangs the analyzer. When every link of a method chain returns a union whose members share the method (Pest's expect(...)->and(...)->toBe(...) chains resolve to Expectation|HigherOrderExpectation at each step), each link resolved the method once per union member and kept the duplicate results, doubling the receiver set at every link. A 20-expectation Pest test built over a million receivers, pinning every core and exhausting memory until the process was killed. Duplicate classes are now dropped as each link resolves, so long expectation chains analyze instantly.
  • A union of two classes with the same short name keeps both halves. Candidate classes were deduplicated by their unqualified name, so a union spanning two namespaces that each declare a Thing (or an Exception, Client, Config, Response, which real projects have one of per namespace) collapsed to whichever came first and lost every member of the other. A @return \NsA\Thing|\NsB\Thing reported Method 'onlyNsB' not found on class 'NsA\Thing', offered only the first class's methods in completion, and had nothing to jump to on go-to-definition. The comparison is now on the fully-qualified name, so both classes survive while genuine duplicates are still dropped and long fluent chains through a union stay fast.
  • Every feature now resolves a type as well as hover does. Three parts of the type engine were switched on for hover, completion, and diagnostics only: inferring a return type from a method body when nothing declares one, resolving the model a Laravel auth guard is configured with, and reading the array shape a validation rules array describes. Every other feature asked the type engine the same question with those parts absent and got a poorer answer for the identical code, so hovering auth('admin')->user()->email named the model's property while go-to-definition on that same email had nothing to jump to. They are now active for every request, so go-to-definition, find-references, signature help, code actions, rename, and inlay hints see what hover sees.
  • "Promote to constructor property" keeps the property's attributes. An attribute on the property being promoted (#[SomeAttr] private int $bar;) was deleted along with the declaration and never re-emitted, so the refactor quietly removed executable metadata that ORMs, validators, and serializers read at runtime. The attributes now move onto the promoted parameter, ahead of the visibility keyword, one #[…] group each so a grouped #[First, Second] still reads clearly. Arguments carry over verbatim, and an attribute the parameter already has is not repeated.
  • Override completion writes static, not $this, as the return type. Completing an override of a method whose return type only exists in PHPDoc as @return $this generated public function withTitle(string $title): $this, which PHP rejects: $this is not a native type hint. The generated signature now uses : static, the native spelling of a fluent return, and the same applies to the "Implement missing methods" quickfix and to unions like $this|null. A @template param is no longer emitted as a hint either: @return T used to generate : T, which PHP reads as a return of the nonexistent class T. Completing an override of a trait method now also restates the trait's docblock-only @param and @return types (and the @template params they use) above the new declaration, since PHP inherits PHPDoc from parent classes and interfaces but not from traits. Only the types the generated signature cannot express are restated, so an override of a plainly typed trait method still comes out bare.
  • A very long fluent chain no longer crashes the language server. A generated query builder or generated API client can produce a method chain thousands of links long, and every stage that walked one, building the file's symbol map, resolving the receiver's type, rendering the expression back to text, spent a stack frame per link. Around a few hundred links that exhausted the stack, and because a stack overflow aborts the process rather than raising an error, the server died and the editor lost every feature until it restarted. Each of those walks now steps along the chain instead of recursing into it, so a chain thousands of links long resolves, hovers, and analyses without taking the server down.
  • A write through __set no longer overrides what __get returns. Assigning to a property a class only has through its magic setter ($bag->a = 9 on a class with __set, whether written from outside the class or from inside it) recorded the written value as the property's type, so the following read came back as 9 instead of the int the documented __get gives you. The setter is free to transform, reroute, or drop the value, so the write says nothing about a later read: reads now resolve through __get as they do without the write. A real declared property, an @property tag, and a dynamic property on a class without __set are all unaffected and still take the written type.
  • A long ?? chain or a deeply nested ternary still resolves. $a ?? $b ?? … ?? $z, a ternary nested in its own else branch, and stacked (…) or @ wrappers were resolved one recursive step per link, and past about a hundred links the resolver gave up and reported no type at all. Completion offered nothing on the result, hover showed nothing, and the branches beyond the cutoff were dropped from the union. All three shapes are now walked without a recursion budget, so every branch that can be reached at runtime contributes its type however long the chain is.
  • A property assigned inside a guarded if keeps that type after the block. The lazy-initialisation idiom (if (!$this->instance instanceof Concrete) { $this->instance = …; }) dropped back to the declared property type once the if closed, so returning the property from a method with the narrower return type was reported as a mismatch and completion on it offered the broader type's members. Both ways out of the guard give the same type, and the merge at the end of the block now says so. A property only narrowed inside one branch still widens back, and a merged Child|Parent union now collapses to Parent even when one side is nullable.
  • A short @implements argument list binds the value parameter. @implements Bag<User> against an interface declared @template TKey of array-key / @template TValue bound User to the key parameter while resolving @method/@property and interface members, the opposite of what the same annotation means everywhere else, so a member typed with the value parameter came out as the raw template name. The interface merge now right-aligns short argument lists the way the main inheritance merge already does, both for the interface's own generics and for the ones collected from a parent's @extends.
  • "Make constructor final" puts the keyword on the constructor. For a constructor with no visibility keyword in a class whose opening brace sits on its own line, the quickfix inserted final in front of that brace, producing code PHP rejects. The same happened when an attribute shared the constructor's line. The keyword now lands on the first real modifier, or on function when there is none.
  • "Promote to constructor property" takes the property's docblock with it. The action deleted the property declaration but left a /** @var int */ above it behind, stranded above the constructor. The docblock is now removed along with the declaration it documents.
  • analyze no 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, so analyze and the editor agree.
  • analyze reports Blade diagnostics on the right line. A type mismatch, unknown member, or unknown variable inside a Blade template was reported six lines above the code that produced it, so the CLI pointed at unrelated markup. The Blade coordinate translation was applied twice; it now happens once, where every other diagnostic already gets it.
  • A rules() method supplied by a trait is now read. A FormRequest that gets its rules() from a trait rather than declaring one offered no request-input keys and no validated() shape, because only the class body and the parent chain were searched. The traits a request uses are now followed as well, in use order and through a trait's own traits, and go-to-definition on a key lands on the trait that declares it.
  • array_pop on a nested array unwraps one level. Popping a list<list<int>> resolved to list<list<int>> rather than list<int>, so iterating the result gave list<int> where it should give int. The same applied to array_shift and the other element-extracting functions whenever the element type was not itself a class. Popping a list<User> was unaffected.
  • instanceof narrowing applies inside a for loop's condition. for ($e = $iter->current(); $e instanceof Foo && $e->x; …) did not narrow $e for the rest of the condition, so completion and hover on $e->x saw the unnarrowed type. if and while conditions already narrowed this way.
  • extends is no longer offered while declaring an enum. Typing enum Foo ext suggested extends, which PHP rejects outright for enums; only implements is valid there. Class and interface headers still offer it.
  • A use \Foo\Bar; import no longer keeps its leading backslash. An import written with the optional leading \ was recorded with it, so the name it resolved to differed from the same class imported without it, and only some resolution paths stripped it. Imports are now normalized when they are read, and the type engine's own copy of name resolution has been replaced by the shared one so the two cannot drift apart.
  • String-argument completion no longer scans the whole file to find its call. The backward search for the ( that opens the argument list had no bound, so an unbalanced bracket earlier in the file (common mid-edit) sent it to the start of the file on every keystroke inside a string literal. It now stops after 2000 bytes, well past the length of any real argument list.
  • A changed @template T = default value invalidates its cache entry. Editing the default of a template parameter (@template TAsync of bool = false to = true) did not count as a change to the class, so conditional return types that depend on that default could keep resolving against the old value until something else in the class changed.
  • A */ inside a Blade comment no longer breaks the rest of the template. Commenting out a block of PHP is the usual reason to write {{-- … --}}, so the comment text routinely contains */. That sequence used to close the comment early, turning the remainder into live code, and everything below it in the template lost completion, hover, and go-to-definition while collecting nonsense syntax errors. The comment text is now neutralized when it is emitted, so only the --}} Blade itself looks for ends the comment.
  • Negating an integer variable stays an integer. -$count on an int resolved to int|float, which modelled the one input (PHP_INT_MIN) that overflows at the cost of precision on every other negation, exactly as PHPStan reports it. Negating an integer literal was already exact.
  • A class named Real is no longer treated as float. real was PHP's pre-8.0 alias for float, and accepting it case-insensitively meant a userland Real class (plausible in a math or finance codebase) compared as a scalar, so genuine mismatches against it went unreported. The alias now applies only to the exactly lowercase spelling, the same rule the number pseudo-type already followed.
  • array_merge(parent::rules(), […]) keeps the parent's keys. A FormRequest that 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 the validated() shape was abandoned. The ancestor's rules are now followed and merged at the position PHP's array_merge would 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 one validate() call in each arm of an if/else, only the last one written described the request afterwards, so the other arm's keys were missing from completion and from the validated() 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 way validated() returns only the last validation's data.
  • An exclude rule no longer leaves its field in the validated() array. Laravel validates an exclude field 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 parent parameter type on an inherited method resolves to the right class. parent binds to the parent of the class that declares the method, but an inherited copy resolved it against the class the call was made on, so calling $leaf->take($base) on a grandchild demanded the wrong class. Both self and parent are now bound to the classes the declaration meant when the method is inherited.
  • A qualified class name resolves against the current namespace first. new View\Event() inside namespace Site resolved to a global Event class when one existed, so every member of the project's Site\View\Event was flagged as unknown. PHP prefixes the current namespace onto any name that does not start with \, whether or not it contains a separator, and an import of the leading segment (use Other\View;) takes precedence over that. Both now resolve the way PHP does, with the global scope reachable through a leading backslash. Closes #299.
  • Functions loaded through a __DIR__-relative require_once chain are indexed. Composer files autoload entries that dispatch to their real definitions with require_once __DIR__ . '/...' (as thecodingmachine/safe does to select per-PHP-version function files) were not followed, so a call like \Safe\base64_decode() was reported as "Function not found" even though it works at runtime. The autoload scanner now follows those requires. (#318)
  • A static call on an unqualified class name resolves against the current namespace first. B::x() inside namespace Src resolved to a global class B when one existed, so methods declared on the sibling Src\B were flagged as unknown. PHP resolves an unqualified class reference to the current namespace; the global class is only reachable as \B. Static access and bare class references now try the same-namespace class before falling back to the global scope, matching the existing behaviour of new B().
  • Base relationship builder methods no longer appear as model properties. hasMany, belongsTo, morphOne and the other relationship builder methods inherited from HasRelationships were incorrectly synthesized as virtual properties (and has_many_count, belongs_to_count, etc. as count properties) on every Eloquent model. Only user-defined relationship methods now produce virtual properties.
  • @method and @property tags on a parent's trait are inherited. A docblock tag declared on a trait was only visible on the class that used the trait directly, not on its subclasses, even though the subclass inherits the trait's real members. The tags now propagate down the whole chain, with the trait's template parameters resolved through both the consumer's @use arguments and the subclass's @extends arguments. Contributed by @shuvroroy (#314).
  • Framework internals no longer appear as properties on Eloquent models. A model that declared no relationships of its own still offered hasMany, belongsTo, morphEagerTo and the rest of the framework's own methods as properties, plus has_many_count and friends as count properties. Only relationships the model itself declares produce properties now.
  • parent::SOME_CONSTANT resolves to a type. A class constant reached through the parent keyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached through self, static, or the class name resolved normally. Constants inherited further up the chain resolve through parent:: too.
  • An explicit @property tag 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|null for 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 @template is bound solely by the parameter being checked, such as assertSame's $expected) could report a type mismatch: the substituted parameter type is derived from resolving that exact argument, so comparing the argument to it again is circular and, when the two resolution passes disagree on an ambiguous expression, produced a spurious diagnostic. Such a parameter is no longer checked against its own argument.
  • self, static, and parent in a parameter type resolve to a real class. A method declared canChangeTo(self $next) used to be checked against the literal keyword, so passing an instance of the declaring class was reported as "expects self, got State". The keywords now resolve wherever the call is made from, including through a property ($this->state->canChangeTo(State::B)), where the enclosing class is not the one declaring the method. self on an inherited method binds to the class that declares it, so a parent instance is still accepted when the method is called on a subclass, and a parent parameter is now checked instead of skipped. Mismatches name the class the keyword resolves to rather than the keyword.
  • Returning a base type where a subclass is declared is now reported. Passing or returning a value whose type is a supertype of what the signature declares (an Animal where a Cat is expected) used to be waved through on the grounds that the value might be the narrower type at runtime. That silence hid a whole class of genuine mistakes, most visibly returning a base type from a method declared to return a specific subclass. Such a downcast is now a type mismatch. Code that proves the narrower type first, with instanceof or a match on the value's class, keeps resolving to it and stays quiet.
  • An Eloquent query resolves to the model's own collection class. Post::where(...)->get(), a relation property, and $post->comments()->get() all resolve to the collection the model actually builds (from #[CollectedBy], @use HasCollection<...>, or a newCollection() override) rather than the base Illuminate\Database\Eloquent\Collection. The collection's own methods complete on the result, hover names the real class, and a method declared to return it type-checks. A self-referential relation (@return HasMany<self, $this>) and a collection class declared in the model's own namespace are both recognized, and a query for a different model resolves to that model's collection.
  • view('name') resolves to the concrete view object. The helper's declared return type is the Illuminate\Contracts\View\View contract, but Laravel's view factory always builds an Illuminate\View\View. Every Blade component's render(): View signature names the concrete class, so the contract-typed result reported a mismatch on correct code. view() with a template name now resolves to the concrete class; with no arguments it is still the view factory.
  • match ($value::class) narrows its subject in each arm. A dispatch table written as match ($node::class) { Foo::class, Bar::class => $this->handle($node), ... } left $node at its declared type, so passing it to a handler typed for the arm's class was reported as a mismatch and completion on it offered the wrong members. Each arm now narrows the subject to the classes it names.
  • An override inherits the @param types of the method it implements. PHP requires an override to restate every native type hint, so an implementation of a generic interface method (processNode(Node $node) under @implements Rule<CallLike>) still receives the narrower type the interface's @param describes. That type is now used inside the method body. An override that deliberately names a different type keeps its own.
  • Template parameters bind from a property argument. @template T of Base bound through @param array<string, T> $items fell back to T = Base when the argument was a property ($this->items) rather than a local variable, widening every result derived from it. The property's declared element type is now used, including when the template sits inside a nested generic such as array<string, array<string, T>>.
  • A class constant reference keeps its declared literal value. Foo::STRING_CONSTANT resolved to the widened base type (string), losing the precision the initializer declares, while the equivalent literal assignment already kept it. Constants with a scalar initializer now resolve to that value ('foo', 1, 3.14) wherever the constant is referenced — including through self::, inheritance, and global const/define() constants — so hover shows the value and match/comparison narrowing can use it.
  • Scalar literals stay precise until PHP requires a broader type. Assignments now retain exact string, integer, and float values through parentheses, unary signs, match expressions, ternaries, and null coalescing, so hover, diagnostics, and inferred PHPDoc return types can use literal unions without reconstructing them in each consumer. Mutable array/list storage widens scalar values at the collection boundary, PHP array-key coercions are reflected in generic key types, and operations that change a value (such as increment/decrement, string-offset writes, arithmetic, and object casts) invalidate only the precision they actually change. Contributed by @snowyukitty.
  • A ternary with a statically-known condition no longer unions in its dead arm. true ? 1 : 2 resolved to 1|2 instead of 1, and false ? 1 : 2 resolved to 1|2 instead of 2, because both branches were always combined regardless of whether the condition could ever take both paths. A condition that is a bare literal (true, false, null, a nonzero/zero number, or a non-empty/empty string) now resolves only the reachable branch, matching PHPStan.
  • A slash in a Route::resource() name no longer produces the wrong route names. Route::resource('photos/comments', …) was read as the nested resource photos.comments, but Laravel treats the slash as a URI prefix and registers the resource comments, so completion and go-to-definition offered names (photos.comments.show) that the application does not have. The names are now comments.show and the URI photos/comments/{comment}. Contributed by @shuvroroy (#308).
  • The contents of a Blade {{-- ... --}} comment no longer desync the rest of the file. An apostrophe or double quote anywhere inside a Blade comment was mistaken for the start of a PHP string literal, so the preprocessor skipped past the comment's actual --}} terminator hunting for a matching closing quote, corrupting everything after it and producing bogus "undefined variable" and "unexpected token" errors far below the comment. Commenting out an echo ({{-- {{ $old }} --}}) and mentioning @endphp in comment prose had the same effect, and an unterminated comment no longer swallows the whole file. Comment text is now treated as text, so only a --}} ends a comment. Contributed by @krist7599555 (#303).
  • Parameter-name inlay hints in Blade templates are no longer filtered against the wrong positions. Deciding which hints fall inside the visible part of the file compared each argument's position in the compiled PHP against a viewport still measured in Blade coordinates, so hints could go missing or appear for arguments that were scrolled out of view. The already-translated viewport is now used.
  • static/$this return types are no longer flagged when passed where a Stringable object is accepted. Passing a value typed static(Foo) or $this(Foo) to a string parameter reported a type mismatch even when Foo implements Stringable, which PHP accepts by calling __toString(). This hit any use of SimpleXMLElement, whose magic __get returns static, so code like (string) $xml->Body->Message reported a false positive on every argument passed to a string parameter.
  • Inline {@see} references nested inside other docblock text are now found. An inline {@see Foo} written in the description of another tag (@param Type $x see {@see Foo}), or nested inside another inline tag ({@deprecated use {@see Bar} instead}), was invisible to go-to-definition, find references, and rename: the previous scan located a reference by searching for the next } after {@see , which stopped at the first brace it met rather than the one that actually closed the tag. Inline {@see} references are now read from the same PHPDoc parse tree as everything else in the docblock.
  • Docblock navigation works in @method and @property tags written across several lines. A tag whose type wrapped onto a continuation line, such as a @method Collection<int, Item> fetchAll(Filter $filter) broken after the <, only ever had its first line read. Everything after it was invisible: go-to-definition, find references, and rename did nothing on the method or property name, on the type arguments, or on the parameter types, and the truncated first line was reported as a class named Collection< that resolved to nothing. Docblock positions now come from the PHPDoc grammar itself, so every name in such a tag is navigable wherever it sits.
  • Docblock navigation lands on the right name in types that mix a * wildcard with a non-ASCII name. In a type such as @return Map<Café, *, User>, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking User resolved nothing (or the wrong symbol). The PHPStan * wildcard is now read directly by the type grammar rather than rewritten to mixed beforehand, which removes the byte-offset bookkeeping that was corrupting the positions.
  • Formatting a short method chain starting with new X(...) no longer breaks it across lines unnecessarily. When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like (new Foo(...))->bar() onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0.
  • @method and @property tags on an implemented interface are now always applied. A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents.
  • Find References, Rename, and Go to Implementation no longer look stalled during startup indexing. A search started while the background index is still parsing the workspace waits for that index to finish, since acting on a partial index would silently miss results. That wait now shows in the request's own progress bar as "Waiting for workspace index" alongside the index's live file counts, instead of sitting at "Resolving…" with no indication of what it is waiting for.
  • Type narrowing against @phpstan-assert/@psalm-assert no longer leaks memory. Evaluating a narrowing call such as Assert::isInstanceOf($x, Foo::class) or a custom function/method with the same annotations allocated a small amount of memory that was never freed. This ran on every conditional touched during completion, hover, diagnostics, and go-to-definition, so memory held by a long-running editor session grew slowly but permanently the more the project was edited. Fixed by no longer leaking the allocation.
  • Renaming a namespace no longer corrupts group use statements. Renaming a namespace segment that is imported with a group use (e.g. use App\Old\{Foo, Bar};) previously rewrote the group's shared prefix and then also spliced the new prefix into each member name, producing invalid PHP like use App\New\{App\New\Foo, Bar};. The member names are left untouched now, since the prefix rewrite alone already updates the whole statement correctly.
  • Parameter name inlay hints no longer shift to the wrong parameter when only part of a multi-line call is visible. Editors request inlay hints only for the currently visible viewport, and when a call's arguments were split across the viewport boundary, every hint after the first excluded argument was labelled with the previous parameter's name instead of its own. This was most noticeable on multi-line constructor calls with several arguments, such as those using constructor property promotion.
  • 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 analyze wall time on large Laravel projects and removing the same overhead from every editor request.
  • Member resolution no longer degrades on deeply nested class dependencies. Class resolution previously cut off at internal nesting limits, silently returning incomplete member sets (missing completions, spurious unknown-member diagnostics) on projects with deeply intertwined hierarchies and virtual members. Those limits are gone: only genuine dependency cycles, such as two Eloquent models whose relationships reference each other, fall back to a partial view, and they now do so deterministically instead of depending on which class happened to resolve first on a given thread.
  • Completion works after closing a multi-line closure argument on the same line as the next chain operator. Typing -> immediately after a call like ->map(function (...) { ... })-> now resolves the full receiver instead of returning no member suggestions until the operator is moved to a new line. Contributed by @calebdw.
  • Conditional return types recognize interpolated strings as strings. A function with a conditional return type like ($key is string ? mixed : null) now correctly resolves the string branch when called with an interpolated string argument (e.g. config("{$prefix}.host")). Previously the interpolated string was not recognized as a string literal, causing the return type to fall through to the else branch and resolve as null. Contributed by @calebdw.
  • Renaming a constructor-promoted property parameter now cascades to $this->prop usages. Renaming private int $someField in a constructor's parameter list previously only updated the parameter declaration itself, leaving every $this->someField reference elsewhere in the class stale. Rename, find references, document highlight, and linked editing now treat a promoted property parameter the same as an ordinary property declaration.
  • Closure/arrow-function parameters passed after reordering named arguments now infer correctly. When a call used named arguments to reorder or skip parameters ahead of a closure argument (e.g. process(class: Product::class, cb: function ($p) {...}, flag: true)), the closure's own parameter type stopped being inferred from the target function/method's callable(...)/Closure(...) signature, silently losing completions and hover for the closure's parameters. Argument-to-parameter binding now follows PHP's actual named-argument rules instead of assuming call position matches declared position.
  • Deprecated enum cases are recognized. Enum cases annotated with @deprecated or #[Deprecated] now carry deprecation metadata, so usages like self::Low can be highlighted as deprecated in contextual semantic-token mode. Contributed by @calebdw.
  • Class constant accesses use constant semantic highlighting. self::CONSTANT, static::CONSTANT, parent::CONSTANT, and ClassName::CONSTANT now emit the enumMember semantic token instead of being colored as properties, while ClassName::$property still emits property. Contributed by @calebdw.
  • PHP attributes use decorator semantic highlighting. Attribute class names in #[...] now emit the decorator semantic token instead of class, so editor themes can color attributes differently from normal class references. Contributed by @calebdw.
  • Go-to-definition on overriding methods jumps to the parent declaration. When the cursor is on a method definition that overrides a parent or implements an interface method, go-to-definition now navigates to the prototype declaration instead of returning call-site references. Similarly, go-to-definition on a class name jumps to the parent class when one exists. Methods and classes that don't override anything still show usages as before. Contributed by @calebdw.
  • Go-to-definition on overridden properties and constants jumps to the prototype declaration. Declaration-site navigation now follows overridden properties and constants to the nearest parent or trait declaration, matching the override behavior for methods. Contributed by @calebdw.
  • Code lens navigation works on Cursor, VSCodium, Neovim, and other editors. Clicking a code lens annotation (e.g. "overrides Parent::method") now uses the standard window/showDocument LSP 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\HttpClient and App\Foo\HttpClient) now resolves correctly instead of returning no results. Contributed by @calebdw.
  • Static method calls resolve return types as accurately as instance calls. Foo::bar() previously missed inference that $foo->bar() already had: return types behind a @phpstan-type alias, inherited return types substituted through a generic interface or trait, and the __callStatic() magic-method fallback. These now resolve the same way for both call styles.
  • Facade static calls keep concrete method return types. Static calls on Laravel-style facades now resolve missing methods through getFacadeAccessor() and facade @mixin targets before falling back to __callStatic(), so values like Driver::details() keep the concrete provider method return type instead of degrading to the facade's broad magic-call return. Contributed by @calebdw.
  • class-string<static> parameters no longer reject sibling subclass constants. Static helper calls from a shared base class that pass concrete ::class constants for sibling subclasses no longer report false argument-type mismatches against class-string<static>. Contributed by @calebdw.
  • class-string<static> parameters now diagnose provably invalid class strings. Passing an unrelated class to a class-string<static> parameter is now flagged as a type mismatch instead of being silently accepted. The diagnostic resolves static to the declaring class at the call site and checks whether the argument class is in the inheritance hierarchy, so child classes and siblings are still accepted while unrelated classes are rejected. static and $this now carry the class they are bound to instead of being flattened to a bare class name, so a value that is "at least this class, possibly a subclass" is no longer mistaken for the class itself. The binding is kept only where PHP actually keeps it open, which is a call that forwards it: $this->, self::, static::, and parent::. Writing the class out pins it, so A::create() on a @return static method resolves to exactly A, and so do new A, a variable declared A, and a ::class string. parent::create() binds to the calling class rather than the parent, @return self stays on the class that declares it, and a first-class callable ($this->create(...)()) resolves the same as the direct call it stands for. Hover shows the difference: where the runtime class is still open the type reads as static(App\Foo) (or $this(App\Foo) for the exact instance), the same notation PHPStan reports, and where it is pinned it reads as the class alone. Contributed by @calebdw.
  • Built-in PHP classes shadowed by vendor polyfills resolve to the real definition. When an installed package ships a polyfill for a PHP built-in (for example symfony/polyfill-php84's RoundingMode), resolution sometimes picked the polyfill's legacy pre-enum declaration instead of the built-in, turning enum cases into plain int constants and reporting false "expects RoundingMode, got int" argument mismatches. Which declaration won could change from one run to the next, making whole-project analysis results nondeterministic. Global names of built-in classes now always resolve to the bundled PHP definition, and classes discovered inside phar archives are indexed in a stable order.
  • Member name positions no longer suggest classes. Typing a name after function, const, or enum case (for example protected function getC) no longer offers unrelated class names from the project. Property names were already safe because they start with $. Contributed by @calebdw.
  • Null-initialized variables reassigned in an untyped foreach are not stuck as null. When the iterable has no known element type (for example an untyped parameter), the loop value is now treated as mixed, so $x = $value after $x = null participates in post-loop merge and is_null early-return narrowing instead of leaving a false null type at later call sites. Contributed by @calebdw.
  • By-reference method out-parameters no longer flag undefined variables. Passing an undeclared variable into a by-ref parameter (e.g. new A()->dosmth($y, $foo) where $foo is &$x) is valid PHP and now defines the variable for later use, matching free functions like preg_match(..., $matches) and $this->method($out). Contributed by @calebdw.
  • Eloquent query chains keep the concrete model through Query mixin fluents. Methods that only exist on Query\Builder (for example lockForUpdate()) and are reached via @mixin no longer drop Builder<TModel> before firstOrFail() / first(), so the result types as the model instead of Model|stdClass. Classes that use Laravel's ForwardsCalls trait apply decorated-forward return semantics on mixed-in methods: self-like or mixin-class returns become the forwarder's $this (preserving generics), while non-self returns pass through. The same path covers relationship chains such as $this->posts()->lockForUpdate()->firstOrFail(). Contributed by @calebdw.
  • Eloquent type inference works for aliased models. Builder and where{Property}() virtual methods now substitute TModel with the model's fully-qualified name instead of its short name, so use App\Models\Channel as ChannelModel next to another Channel import still types ChannelModel::whereName(...)->firstOrFail() as App\Models\Channel. Contributed by @calebdw.
  • Linux binaries run on any distribution. The Linux language server is now a statically linked build with no minimum glibc version, so it starts on old-glibc distributions (RHEL/CentOS 8, Debian 11) and musl-based ones (Alpine) where the previous build failed with GLIBC_… not found or a missing dynamic loader. Whole-project analysis is slightly faster than the previous Linux build, and idle memory usage is lower on many-core machines.
  • Deeply nested functional-style code no longer crashes the analyzer. Nested calls that pass closures or arrow functions to array_map, array_filter, and similar (for example an array_filter(array_map(fn(...) => ..., array_filter(...)), fn(...) => ...) chain) previously overflowed the stack and aborted the language server. Such expressions now analyse to completion.
  • Editing a file while the workspace is indexing no longer shows stale results. A file you opened and edited during the background index (or during the index a Find References triggers) kept its hover, diagnostics, and references computed from the pre-edit version on disk until the next keystroke. Open buffers now keep their live edited state throughout indexing.
  • Custom Blade view directories are recognized. Projects that register non-default view paths in config/view.php (for example a resources/backoffice/views directory) no longer see valid view() names flagged as unknown, and go-to-definition and hover resolve those templates to the file under the configured root. View names are discovered by scanning the configured directories on disk, so templates are found even before they're opened.
  • Several Blade directives no longer produce cascading false-positive diagnostics. @class, @style, @checked, @selected, @disabled, @readonly, @required, and @stack previously corrupted everything after them in a template when used inline (for example <div @class(['active' => $isActive])>), reporting dozens of unrelated syntax errors for the rest of the file. $errors and $__env are now visible everywhere in a template instead of only sometimes. @unless, @isset, and @empty(...) no longer leave a dangling parenthesis that broke every diagnostic after them. A literal <?xml ... ?> declaration in a template (e.g. an RSS or sitemap feed) is no longer misread as a PHP tag. @json($var) and @dump($var) are now recognized directives, so a variable used only inside one of them is no longer flagged as unused. @use(...) and @inject(...) no longer swallow the rest of the template either: @use('App\Models\Post') now imports the class so its short name resolves in the template (aliases, the two-argument alias form, grouped imports, and the function/const modifiers are handled), and @inject('svc', 'App\Service') defines the injected variable so it resolves and is not reported as undefined.
  • Blade component bound attributes are analysed as PHP. The expression in a bound attribute such as :src="$image", :key="$item->id", or the :$message shorthand is now understood as real PHP. A variable used only in a bound attribute is no longer flagged as unused, and hover, go-to-definition, and completion work inside the expression. Colons that are not bindings (an attribute value like href="mailto:x", a 10:30 in text, or an escaped ::class) are left untouched.
  • PHPStan diagnostics no longer report false positives from location-aware rules while you edit. Rules that depend on where a file lives (for example Larastan flagging env() calls outside the config/ directory) previously fired on every matching call because the unsaved buffer was analysed from a scratch location. PHPantom now analyses the file at its real path when the buffer matches what's on disk, and otherwise substitutes the buffer in place, so these rules see the file's true location and agree with a plain command-line PHPStan run.
  • Eloquent models always expose their primary key. A model whose table has no migration and no schema dump no longer reports a false Property 'id' not found on $model->id. The primary key is synthesized for every Eloquent model, honouring $primaryKey for the column name and $keyType for the type (int by default, string for UUID or ULID keys).
  • Laravel virtual property hover and navigation prefer backing accessors. Hover labels for accessor and computed Eloquent properties now describe the source without echoing the backing method name, and go-to-definition on accessor/computed properties prefers the backing accessor or legacy mutator before falling back to $appends or other Eloquent metadata arrays. Legacy setXAttribute mutators are shown alongside database, cast, attribute-default, accessor, or computed-property sources when they exist. Contributed by @calebdw.
  • Laravel macro callbacks registered through facades now infer $this as the concrete facade target. $this inside callbacks such as Request::macro('shouldReturnJson', function () { ... }) and Context::macro(...) now resolves to the class behind the facade instead of the surrounding service provider. Contributed by @calebdw.
  • self:: and static:: inside macro callbacks resolve to the macro target. In a closure passed to a macro() registration (Laravel Macroable or Carbon), self:: and static:: now resolve to the class the macro is registered on instead of the service provider that lexically encloses the registration, matching how the closure is bound at runtime. Carbon's static macro idiom self::this()->... no longer reports a false unknown-method diagnostic, and completion after self:: inside the callback offers the target's members.
  • Find References and Rename no longer match unrelated same-named methods when a call's receiver type can't be resolved. A call like $x->find() used to conservatively match every find() method in the project when $x's type couldn't be determined, drowning results in unrelated matches for common method names. Find References on an interface method now also includes every implementing class; Rename Symbol stays scoped to the single concrete implementation being renamed, so it never rewrites unrelated same-named methods elsewhere. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/186.
  • Go-to-implementation and type hierarchy return the same results every time. After the workspace finished indexing, these features listed only your project's implementing classes, but a class from a dependency could slip in if you happened to have viewed it earlier in the session, so the same query gave different results depending on what you'd looked at. Results are now consistently limited to your own code, matching what the workspace index actually covers.
  • Blade files with raw <?php ... ?> tags no longer report false syntax errors. PHP code embedded directly in a Blade template (outside @php/@endphp) is now recognized and passed through unmodified, so string literals that happen to start with @ (e.g. a JSON-LD '@context' array key) are no longer misread as Blade directives.
  • @switch/@case with a class-constant case value no longer reports a syntax error. @case (Some\Namespaced\Enum::VALUE) now translates to a valid case arm instead of silently corrupting the rest of the file.
  • Generated return types and @return tags understand every kind of return expression, not just literals and variables. Inferring a missing return type now resolves method/function calls, ternaries, matches, property access, and array literals split across multiple lines through the same type engine as hover, instead of degrading to mixed (or, for multi-line array literals, to a coarser array<mixed>) for anything beyond a simple literal, new, or a plain variable.
  • Calls to functions declared in another namespace block of the same file resolve their return type. In a file that declares more than one namespace, a call to a function from a later block used to leave the returned value untyped unless the function carried an @return docblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type.
  • @template bindings resolve correctly when a call uses named arguments. A generic function or method called with named arguments (for example process(class: Product::class, flag: true, function ($p) { ... })) previously bound its template parameter from the wrong argument whenever the named arguments were out of declaration order, leaving closure parameters untyped. Named arguments now route to the parameter they actually target.
  • Vendor-provided functions and constants no longer rank as project-native in completion. With the self, full, and composer indexing strategies, functions and constants discovered while scanning vendor packages lost their package origin when merged into the workspace scan, so they sorted ahead of other vendor symbols as if declared in the project itself instead of by their actual dependency tier.
  • Argument-count and argument-type diagnostics no longer mix up calls that share the same text but resolve differently. A per-file cache reused the first resolved target for every call site with the same expression text, without accounting for the fact that self::method(), static::method(), and parent::method() resolve differently depending on which class they appear in, and $var->method() resolves differently depending on what type $var holds at that call site. Two classes each declaring their own self::make() with a different required argument count, or two methods each assigning a different type to a same-named variable before calling the same method name on it, could silently report the wrong argument-count or argument-type diagnostic (or miss one) on every call after the first. These forms are now always resolved fresh per call site.

0.9.0 - 2026-07-20

Added

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

Changed

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

Fixed

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

0.8.0 - 2026-05-14

Added

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

Changed

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

Fixed

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

0.7.0 - 2026-04-08

Added

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

Changed

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

Fixed

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

0.6.0 - 2026-03-26

Added

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

Changed

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

Fixed

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

0.5.0 - 2026-03-12

Added

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

Changed

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

Fixed

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

0.4.0 - 2026-03-01

Added

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

Fixed

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

0.3.0 - 2026-02-21

Added

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

Fixed

  • PHPDoc type parsing and internal stability fixes.

0.2.0 - 2026-02-18

Added

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

Fixed

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

0.1.0 - 2026-02-16

Initial release.

Added

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