phpstan-laravel-deadcode maintained by amarucci
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:
- it is a framework entry point —
render,mount,boot,rules,queryString,with, a per-property hook such asupdatedTitle, or a per-trait hook such asbootWithCurrentTeam(Livewire calls{hook}{TraitBasename}once per trait of the component); - it is called from the browser payload under Livewire's underscore
convention —
_finishUpload,_uploadErrored, the file-upload protocol; - it carries an attribute driving it from outside —
#[On],#[Url],#[Computed],#[Modelable],#[Reactive],#[Locked],#[Session],#[Js],#[Renderless]; - 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\CreatePost → livewire.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/viewNamespacePatternsmakes 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 everyBlade::anonymousComponentPath(). @usealiases in templates —@php use App\Support\ChartHelper as Chart; @endphpfollowed byChart::slices()is matched on the short nameChart, not on the target. It over-spares a class namedChartif one exists, and defendsChartHelperonly if some other template names it in full.<!--inside an HTML attribute —x-data="{ s: '<!--' }"is not shielded, unlike<script>blocks.- Unresolvable templates — see above: the extension abstains rather than accuse.
- Marker interfaces documenting nothing —
Maatwebsite\Excel\Concerns\WithHeadingRowisinterface WithHeadingRow {}, and the package looks forheadingRow()withmethod_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 (WithChunkReadingwantschunkSize()). 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 calledupdate. 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.