Looking to hire Laravel developers? Try LaraJobs

phpstan-laravel-deadcode maintained by amarucci

Description
Teaches PHPStan's dead code detector to read the wiring Laravel calls and PHP cannot see: Livewire components and their Blade templates, Eloquent enum casts, gate abilities, and package contracts.
Author
Andrea Marucci
Last update
2026/09/02 23:01 (dev-main)
License
Links
Downloads
5

Comments
comments powered by Disqus

amarucci/phpstan-laravel-deadcode

Teaches shipmonk/dead-code-detector to read the wiring Laravel calls and PHP cannot see: Livewire components and their Blade templates, Eloquent enum casts, gate abilities, and package contracts.

The detector comes with it. extension.neon includes shipmonk's own rules.neon, so one include is all a consumer writes — and it pins the five detect families on, so an upstream default cannot silently switch one off. The one upstream default it overrides is documented in Configuration.

The problem

A Livewire component has almost no visible link to the rest of the PHP code:

class Counter extends Component
{
    public int $count = 0;              // read by the Blade template

    public function increment(): void   // called from the browser payload
    {
        $this->count++;
    }
}

The dead code detector sees none of that and reports all three members as unused. Its built-in BladeUsageProvider only follows the data passed to view('tpl', [...]); it never reads Blade sources.

Blanket-sparing every public member silences the noise but creates a total blind spot: deleting the button that called resetCount() no longer produces any signal.

Three other families of members are wired up the same invisible way, and this extension covers all of them:

Family Who addresses the member Read below
Livewire\Component the component's own templates, or Livewire itself How members are judged
Livewire\ComponentHook ComponentHook::callX(), through method_exists() Component hooks
Illuminate\View\Component Component::data(), which hands everything to the template Blade class components
any class a Class::member reference written straight into a template Static references from templates

How members are judged

A public member of a Livewire component is considered used when one of these holds:

  1. it is a framework entry point — render, mount, boot, rules, queryString, with, a per-property hook such as updatedTitle, or a per-trait hook such as bootWithCurrentTeam (Livewire calls {hook}{TraitBasename} once per trait of the component);
  2. it is called from the browser payload under Livewire's underscore convention — _finishUpload, _uploadErrored, the file-upload protocol;
  3. it carries an attribute driving it from outside — #[On], #[Url], #[Computed], #[Modelable], #[Reactive], #[Locked], #[Session], #[Js], #[Renderless];
  4. its own component's templates name it.

Otherwise it is reported as dead.

An accessor counts as named when the template addresses the property it stands for, which is the only spelling that ever appears: getIntroProperty() is read as {{ $this->intro }}, getTeamColors() as {{ $teamColors }}.

Per-component templates

Templates are resolved from the view('…') literals found in the component class and from the conventional name (App\Livewire\Posts\CreatePostlivewire.posts.create-post).

Namespaced names are resolved too — view('billing::pages.invoice'), the form every module and every package uses — provided the namespace is declared under viewNamespaces. A published override in resources/views/vendor/billing/… takes precedence, since that is the copy that actually renders.

Pooling names from every template in the project would be simpler but useless: Livewire action names repeat heavily — save, delete, close, submit — and a single wire:click="save" anywhere would spare every save() in the codebase.

Resolution follows what a template pulls in, transitively: @include, @includeIf, @includeWhen, @includeUnless, @includeFirst, @each, and anonymous Blade components such as <x-form.field />. A partial shares the scope of the view including it, so skipping them would report live members as dead.

Recognised reference forms

Form Example
wire: directive wire:click="increment", wire:model.live="title"
Alpine $wire.increment()
JavaScript @this.increment()
computed property {{ $this->fullName }}
Blade variable {{ $count }}, @if ($count > 0)
bare quoted string confirmAction="delete", @props(['closeMethod' => 'closeModal'])
bare string in the class 'actionSubmit' => $this->isMuted ? 'unmute' : 'mute'

That last form is not decoration. A handler name routinely travels as a string to a component that wires it up dynamically:

<x-modal.confirm confirmAction="delete" />        {{-- the caller passes it --}}
@props(['closeMethod' => 'closeModal'])           {{-- the callee defaults it --}}
<button wire:click="{{ $confirmAction }}">        {{-- neither names it here --}}

delete and closeModal are named nowhere else in the component's templates. Reading whole-string identifiers is what keeps that entire family of handlers off the report; on a real application it was the largest source of false positives by a wide margin — 250 of them on a 360-finding run, every one a live handler. class="mt-1 flex" contributes nothing, since only a string that is exactly one identifier counts.

The component's own class source is read the same way, for the case where the template shows nothing but a variable:

return view('conversation::mute-conversation', [
    'actionSubmit' => $this->isMuted ? 'unmute' : 'mute',   // the only spelling
]);
<x-modal.confirm :confirmAction="$actionSubmit" />          {{-- names neither --}}

Nothing else plausibly writes a member's own name as a bare string inside its own class, which is what keeps that over-sparing narrow.

Blade {{-- … --}} and HTML <!-- … --> comments are stripped first: what they mention is dead. <script> blocks are set aside beforehand, otherwise a const marker = "<!--"; would swallow the code following it.

When the extension abstains

A component whose templates cannot be resolved — a dynamically chosen view with no readable literal — has all its public members spared. Staying silent when nothing can be asserted is the only acceptable behaviour; the opposite would have people delete live code.

Abstention is safe but it is not free: a project whose roots are not configured resolves nothing, abstains everywhere, and reports an empty, reassuring list. If the extension never accuses anything in your codebase, check viewNamespaces before congratulating yourself.

Component hooks

Livewire\ComponentHook subclasses — registered with Livewire::componentHook() — are driven entirely by the abstract parent:

function callHydrate(...$params) {
    if (method_exists($this, 'hydrate')) $this->hydrate(...$params);
}

No call site exists for hydrate() anywhere, in the hook or outside it. The lifecycle names are read back from the call* methods of the installed ComponentHook and unioned with a literal list, so a Livewire release that adds one — renderIsland in Livewire 4 — cannot turn live code into a finding.

A hook has no template, so its lifecycle methods are its entire external surface. Anything else on it is an ordinary method with ordinary call sites, and is left to the detector.

Blade class components

Illuminate\View\Component::data() hands every public property and every public method to the template, which is usually the only place either is ever read:

class IssueBanner extends Component
{
    public ?string $message = null;   // {{ $message }}, and nowhere else

    public function render(): View
    {
        return view('billing::components.issue-banner');
    }
}

They are judged exactly like Livewire members, against the component's own templates. The one difference: there is no naming convention to fall back on, so the view has to be named by a view('…') literal in the class; otherwise the extension abstains.

Static references from templates

Templates address classes directly, and nothing in PHP records it:

@foreach (\App\Support\ChartHelper::slices($value) as $slice)
@if ($status === Status::Draft)

Left alone, the detector reports slices() and Status::Draft as dead — and that finding is the dangerous kind: delete the method on its word and its private callees are reported next, as transitively unused.

Every template under every configured root is therefore scanned once for Class::member references, covering static methods, class constants, enum cases and static properties. Unlike member names, a static reference carries its class, so pooling project-wide is precise enough to be useful.

Matching is on the short class name: Status::Draft in a template says nothing about which Status was imported. Two classes sharing a short name therefore defend each other's members — over-sparing, never accusing, which is the direction any doubt has to fall in here.

Declarations inside traits

PHP flattens traits, so a component's entry points are already defended on the using class: IndexExperience::updated() survives because updated is a lifecycle name. But the detector records the declaration against the trait — WithExperienceFilters::updated — and a trait is a subclass of nothing, so no verdict ever reaches the only ref that gets reported.

Each member a class inherits from a trait is therefore judged in the using class's context, where the templates and the conventions are known, and the usage is recorded against the trait.

The reverse direction is covered too. A trait calling $this->resetPage() addresses a member the using class declares, and that call is often its only call site — one file the detector attributes to neither of them. So a trait's $this-> calls count as addressing sites for the class that uses it. Only the trait's, never the class's own: sparing those would break the transitive pass, which is what reports a helper reached solely from an already-dead method.

Beyond Livewire: framework wiring with no call site

Three more families where Laravel calls code the analysis cannot see. Each is a separate provider, so they can be reasoned about — and removed — one by one.

Enum cases an Eloquent model casts

The one blind spot that breaks production rather than merely annoying. HasAttributes::getEnumCaseFromValue() calls $enumClass::from($value), so any row in the column instantiates its case with nothing naming it. Remove the case and reading that row throws a ValueError.

Any enum named inside a model marks all its cases used — deliberately wider than $casts alone, because a model referencing an enum does so about an attribute, and in each of those forms the enum comes from stored data.

Measured on a real application: three cases of a publishing-status enum were removed as dead, and the test suite caught it only because one fixture happened to seed one of the three values. The other two would have shipped.

Contracts

An interface method the detector reports because the call it sees — app(Contract::class)->method(), how a modular application crosses its own seams — is attributed to the implementation instead. Sparing the declaration loses nothing: an interface method is a contract, not code, and if the implementations are genuinely unused the detector still reports them, which is where the removable code lives.

A package may also state a convention its marker interface cannot declare. Fortify's ResetsUserPasswords is interface ResetsUserPasswords {} with @method void reset(User $user, array $input) in its docblock; that annotation is the contract in machine-readable form, and is taken at its word.

Gate abilities

$user->can('adoptReferentialVersion', $version) reaches ReferentialVersionPolicy::adoptReferentialVersion() through the gate registry, resolved at runtime from the model's class. The ability string says which method, never which policy, so the usage is emitted without a class — shipmonk supports a class-less ref for exactly this.

Do not spare Policy methods on principle instead: on the application this was built against, of three Policy methods reported dead, two were live behind can('…') and the third was genuinely dead. Only the string says which.

Installation

composer require --dev amarucci/phpstan-laravel-deadcode

With phpstan/extension-installer you are done. Otherwise include it manually — one line, the detector included:

includes:
    - vendor/amarucci/phpstan-laravel-deadcode/extension.neon

You do not need to require shipmonk/dead-code-detector yourself, nor include its rules.neon: this package requires it and includes it. Doing both is harmless, but redundant.

Configuration

Four settings. The defaults suit a standard Laravel application, where every template lives under resources/views:

parameters:
    laravelDeadCode:
        viewPaths:
            - %currentWorkingDirectory%/resources/views
        viewNamespaces: []
        viewNamespacePatterns: []
        contractConventions: []

viewPaths holds the roots for names carrying no namespace — view('livewire.counter'), and every <x-…> tag. A * is expanded, so one line can cover several roots.

viewNamespaces and viewNamespacePatterns hold the roots for namespaced names, anything registered with loadViewsFrom(). A modular application must declare them, or the extension resolves nothing, abstains everywhere, and silently spares the whole codebase — an empty report is the symptom.

viewNamespacePatterns is the one a modular application wants: the * names the namespace it serves, so one entry covers every module.

parameters:
    laravelDeadCode:
        viewNamespacePatterns:
            - %currentWorkingDirectory%/app-modules/*/resources/views
            - %currentWorkingDirectory%/vendor/acme/*/resources/views

That maps view('billing::pages.invoice') onto app-modules/billing/resources/views/pages/invoice.blade.php, for every module, without enumerating them. It is a list, not a mapping, precisely so both lines above can coexist: a NEON mapping cannot hold a * key twice.

Use viewNamespaces for a root whose namespace is not its directory name:

        viewNamespaces:
            billing: %currentWorkingDirectory%/packages/invoicing/views

A package registering both a namespace and anonymous components needs its root in viewPaths and covered by a pattern. <x-filters.page> resolves to components.filters.page with no namespace, so only viewPaths is searched for it — while ui::… names need the pattern. Declaring one and not the other is the most common way to get a wrong report out of this extension.

What it sets on the detector

Two things, both overridable — your own parameters are applied after this extension's, so the last word is yours.

shipmonkDeadCode.detect is restated with all five families on. Identical to shipmonk's defaults today; the point is that it stays so, since reporting those five is why this extension exists.

shipmonkDeadCode.usageProviders.composer.enabled is set to false — the one upstream default this package overrides. shipmonk's composer provider treats what composer.json's autoload entry points reach as used, which is not what you want while hunting dead members in an application. Set it back to true if your project relies on it.

Everything else is shipmonk's own, including its Laravel, Eloquent and Blade providers: those auto-enable when laravel/framework is installed, and this extension complements them rather than replacing them.

contractConventions maps an interface onto the methods its package calls on implementors without declaring them — the empty-marker-plus-method_exists shape. It ships with the Laravel Excel entries, each read off the guard that calls it:

        contractConventions:
            Maatwebsite\Excel\Concerns\Import:
                - headingRow
                - rememberRowNumber

Add your own the same way: find the method_exists($x, 'name') in the package and key it on the interface that gates it. Do not guess from the interface name — WithHeadingRow wants headingRow(), but its sibling WithChunkReading wants chunkSize().

A * is expanded by glob(), one path segment at a time; ** is not supported. Roots that do not exist are dropped.

Anonymous component paths count as roots

Blade::anonymousComponentPath() registrations belong in viewPaths, pointing at the directory above components/:

        viewPaths:
            - %currentWorkingDirectory%/resources/views
            - %currentWorkingDirectory%/vendor/acme/ui/resources/views

Without them a <x-filters.page> in a component's template resolves to nothing, the include graph stops there, and every handler the shared component wires up — wire:click="resetFilters()" and its like — reads as dead.

Result cache

The extension registers a ResultCacheMetaExtension hashing the path and content of every template.

Without it PHPStan would only invalidate on PHP, config or composer.lock changes, so removing a {{ $count }} from a template would leave the previous verdict in place. Paths are hashed as well as contents, because renaming a template changes which members a component can address.

Note that any template change invalidates the whole cache and triggers a full re-analysis. That is the cost of correctness.

Known limitations

  • Undeclared view roots — a namespace missing from viewNamespaces / viewNamespacePatterns makes every component rendering through it unresolvable, so all their members are spared. Silent, and the reason an empty report deserves suspicion. A missing anonymous root is worse: resolution succeeds, the include graph is simply cut short, and members the shared component wires up are accused. Declare every Blade::anonymousComponentPath().
  • @use aliases in templates@php use App\Support\ChartHelper as Chart; @endphp followed by Chart::slices() is matched on the short name Chart, not on the target. It over-spares a class named Chart if one exists, and defends ChartHelper only if some other template names it in full.
  • <!-- inside an HTML attributex-data="{ s: '<!--' }" is not shielded, unlike <script> blocks.
  • Unresolvable templates — see above: the extension abstains rather than accuse.
  • Marker interfaces documenting nothingMaatwebsite\Excel\Concerns\WithHeadingRow is interface WithHeadingRow {}, and the package looks for headingRow() with method_exists. No signal in the code relates the two, and deriving the method from the interface name holds there but not for its own siblings (WithChunkReading wants chunkSize()). Closing that family needs a per-package map, which is a different kind of thing from the rules above.
  • Abilities are matched by name alone — a class-less ref spares every method of that name, whatever its class. Harmless for adoptReferentialVersion, wider for an ability called update. Measured on the application this was built against: two intended findings closed, nothing else.
  • A trait member reached only by ordinary PHP — a trait method called as $object->method() on a class that uses the trait is reported on the trait, and this extension does not close it. The verdicts here are about framework wiring; that call is plain PHP the detector simply attributes to the class instead. Papering over it would mean sparing every trait member of every using class, which would blind the detector to genuinely dead trait code.

Scope

The package started at Livewire and its templates; the families above reach into Laravel wiring more broadly — Eloquent casts, gate abilities, package contracts. That is deliberate: they are the same kind of problem, a call the framework makes where the analysis sees none, and splitting them across packages would only make a user install two.

The name has not caught up, and will be revisited. Each rule is a separate provider with its own registration, so nothing here depends on the decision.

License

MIT.