shipmonk/dead-code-detector
PHPStan extension that finds unused PHP code: dead methods, constants, enum cases, properties, dead cycles, and transitively dead members. Detects dead tested code and can automatically remove unused code. Supports Symfony and other libraries with customizable usage providers.
Minimal viable setup for a Laravel app. Install as a dev dependency alongside Larastan (you want Laravel's type inference, not just dead-code rules):
composer require --dev shipmonk/dead-code-detector larastan/larastan
Then wire it into phpstan.neon.dist. The single most important detail is paths: the detector only sees usage it scans, so routes/, database/, config/ and tests/ must be in scope, not just app/.
includes:
- vendor/larastan/larastan/extension.neon
- vendor/shipmonk/dead-code-detector/rules.neon
parameters:
level: 6
paths:
- app
- routes
- database
- config
- tests
tmpDir: var/phpstan
shipmonkDeadCode:
usageExcluders:
tests:
enabled: true
Run it:
vendor/bin/phpstan analyse --memory-limit=2G
Where to look first. Don't read the full report. Triaging the first 20–30 errors by category tells you everything about how much setup you still owe:
Unused App\Jobs\Foo::handle / controllers / commands → your routes/, app/Console/Kernel.php or bootstrap/app.php aren't being scanned, or routing is done in a package.scopeXxx, boot() → usually fine out of the box (Eloquent provider), but check for custom traits.First use case worth doing today. Enable the tests excluder (as above) and run the whole codebase. It surfaces the class of code that looks alive only because a test asserts on it — legacy validators, unused formatter branches, defensive helpers — which is exactly the dead weight that accumulates in mature Laravel apps and which no other PHPStan rule reports.
Verdict for a Laravel team: high value, medium setup effort. Most of the effort is not installing the tool but teaching it the parts of your app that use framework magic the Laravel provider doesn't cover (Blade, Livewire/Filament/Inertia/Nova if present).
Baseline config that survives a real app. Keep dead-code detection in the same PHPStan run as everything else initially (one report, one CI job), and only split it out later if the noise bothers you:
parameters:
shipmonkDeadCode:
detect:
deadMethods: true
deadConstants: true
deadEnumCases: true
deadProperties:
neverRead: true
neverWritten: false # models often write attributes Eloquent reads magically
reportTransitivelyDeadMethodAsSeparateError: false
Leave reportTransitivelyDeadMethodAsSeparateError: false (the default): reporting one root cause with "💡 Thus …" tips is far more actionable than 200 errors for a single orphaned facade method.
CI pattern: observe → ratchet → enforce.
continue-on-error: true, collect the report as an artifact, triage categories.- run: vendor/bin/phpstan analyse --memory-limit=2G --error-format=github
Automatic removal workflow. This is the feature that earns the package its place, but treat it as a destructive migration:
git switch -c chore/dead-code
vendor/bin/phpstan analyse --error-format removeDeadCode --memory-limit=2G
git diff --stat # review before anything else
vendor/bin/phpunit # tests are intentionally NOT removed
git commit
Because the tests excluder keeps test-only usages in place, the removal output annotates what it kept and why (! Exclud…) — useful for finding tests that exist solely to keep dead code green. Delete those tests deliberately afterwards.
Custom usage provider for app-specific magic. Livewire, Filament, Inertia pages, Nova resources and any in-house __call-style dispatcher are invisible to the detector. Start with the reflection-based provider — it covers the majority of "this method is called by naming convention" cases with ten lines:
use ReflectionMethod;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;
use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData;
final class LivewireUsageProvider extends ReflectionBasedMemberUsageProvider
{
public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData
{
$class = $method->getDeclaringClass();
if ($class->isSubclassOf(\Livewire\Component::class)) {
// mount(), hydrate(), updatedX(), rules(), render(), computed props...
return VirtualUsageData::withNote('Livewire lifecycle / action');
}
return null;
}
}
services:
-
class: App\Phpstan\LivewireUsageProvider
tags:
- shipmonk.deadCode.memberUsageProvider
Reach for the raw MemberUsageProvider (AST-based, getUsages(Node $node, Scope $scope)) only when the usage is deducible from a call site — e.g. a View::make('users.show', ['user' => $dto]) convention, or a serializer that hydrates DTOs by class-string. Always emit UsageOrigin::createRegular($node, $scope) so transitive elimination stays correct.
Excluder pattern for public surface. Packages and modules with a deliberately public API (a src/Support namespace consumed by other repos, or methods called from config strings) are better excluded than annotated:
final class PublicApiExcluder implements MemberUsageExcluder
{
public function shouldExclude(ClassMemberUsage $usage, Node $node, Scope $scope): bool
{
return str_starts_with($usage->getMember()->getClassName(), 'App\\Support\\');
}
}
Tag it with shipmonk.deadCode.memberUsageExcluder. Excluders run after providers, so they win.
Laravel features that already work — don't reimplement them in a provider: route registration (callable, Controller@method, invokable), Event::listen/subscribe and auto-discovered handle*, Schedule::job(), gates/policies incl. $this->authorize(), real-time facades, console commands, jobs, providers, middleware, notifications, form requests, mailables, broadcast events, JSON resources, Eloquent scopes/relations/accessors, observers (Model::observe() and #[ObservedBy]), factories, seeders and migrations (up/down). Also rely on the vendor/builtin providers: any override of a vendor/ or PHP-core method is considered used — so handle(), getIterator(), boot() from a package base class are never false positives.
paths list. Route files, bootstrap/app.php, app/Console/Kernel.php, provider register() bodies and tests must all be analysed. vendor/bin/phpstan analyse app will lie to you..blade.php will be reported. Three options, in order of preference: (1) an excluder for view-model/DTO namespaces, (2) a reflection-based provider marking ViewModel-marked classes as used, (3) php artisan view:cache then adding storage/framework/views to paths — it works but pollutes the report with compiled-file paths and must not be committed.config/queue.php, config/auth.php provider arrays, 'App\Jobs\Foo' in DB rows, class_exists() strings. Put config/ in paths (helps for ::class constants) and keep a provider/excluder for the rest.$this->{$method}(), app($class), resolve(), container make() with a variable — these tend to mark things used. That's a false negative, not a false positive: the tool is conservative, so a clean report is strong evidence, a dirty one isn't proof.neverWritten fires on properties Eloquent writes magically. Keep neverRead: true, neverWritten: false until you've triaged. Likewise, #[Column]-style enum cases are safe (Doctrine provider), but enum cases referenced only via Rule::enum(Status::class) may need a small provider — prefer Status::cases() in the rule, or add the provider.test* methods, data providers, #[Test]/#[Before] attributes and annotations are covered; Pest's it('…', fn () => …) bodies aren't class members, so nothing is lost — but unused functions aren't detected at all by this package, only class members.removeDeadCode is destructive and format-specific. Run it on a clean working tree, never with --generate-baseline, and re-run PHPStan afterwards: removal invalidates the result cache and can expose a second wave of transitively dead members. Review the diff — it removes constants/enum cases along with methods.shipmonk.deadMethod, plus the constant/enum/property identifiers) scoped by path:, and treat them as a TODO list.tmpDir inside the project, raise --memory-limit, keep PHPStan's result cache warm in CI, and accept that this job runs on the merge target rather than per-push.usageExcluders.tests → path-scoped ignoreErrors → reflection-based provider → AST provider → custom excluder. Only write AST providers for calls you can verify against the framework source; a provider that over-reports silently hides real dead code forever.laravel/framework is in composer deps. Pin the package alongside larastan and bump them together — provider coverage grows release to release (the Laravel/Eloquent providers are relatively recent, so a report that looks wrong may simply be an old version).How can I help you explore Laravel packages today?