Skip to content

0.10.0

Latest

Choose a tag to compare

@AJenbo AJenbo released this 20 Aug 01:22
· 28 commits to main since this release
Immutable release. Only release title and notes can be modified.

What's Changed

Added

Whole-project analysis

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

Blade templates

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

Laravel

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

Diagnostics

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

Type inference

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

Editing and navigation

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

Tooling and platform

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

Changed

Behaviour

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

Performance and memory

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

Removed

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

Fixed

  • 412 bug fixes. (More than will fit in the 128kb limit of a GitHub release note!) Please see the changelog for the full list.

New Contributors

Full Changelog: 0.9.0...0.10.0