Dead Code Detector
A PHPStan extension for finding unused PHP code.
Dead code detector for PHP
PHPStan extension to find unused PHP code in your project with ease!
Summary:
- ✅ PHPStan extension
- ♻️ Dead cycles detection
- 🔗 Transitive dead member detection
- 🧪 Dead tested code detection
- 🧹 Automatic removal of unused code
- 📚 Popular libraries support
- ✨ Customizable usage providers
Installation:
composer require --dev shipmonk/dead-code-detector
Use official extension-installer or just load the rules:
# phpstan.neon.dist
includes:
- vendor/shipmonk/dead-code-detector/rules.neon
Usage:
$ vendor/bin/phpstan
[!NOTE] Make sure you analyse whole codebase (e.g. both
srcandtests) so that all usages are found.
Slides:
Check out the recording and slides from TechMeetup Conference (2025) about this package:
Detected class members:
All dead class member types are detected by default, you can disable some if needed:
parameters:
shipmonkDeadCode:
detect:
deadMethods: true
deadConstants: true
deadEnumCases: true
deadProperties:
neverRead: true
neverWritten: true
Supported libraries:
Symfony:
- Calls made by DIC over your services (constructors, calls, factory methods), requires either of those:
phpstan/phpstan-symfonywithcontainerXmlPathshipmonkDeadCode.usageProviders.symfony.containerXmlPathsconfigured
#[AsEventListener],#[AsMessageHandler],#[AsController],#[AsCommand]#[Assert\Callback],#[Interact],#[Route],#[Required](methods and properties)#[AsSchedule],#[AsCronTask],#[AsPeriodicTask]#[AutoconfigureTag('doctrine.event_listener')],#[Autoconfigure(constructor:)],#[Autoconfigure(calls:)],#[AutowireCallable]defaultIndexMethodanddefaultPriorityMethodin#[AutowireLocator],#[AutowireIterator],#[TaggedIterator],#[TaggedLocator]- Workflow event listener attributes:
#[AsAnnounceListener], ... EventSubscriberInterface::getSubscribedEventsonKernelResponse,onKernelRequest, etc!php/constand!php/enumreferences inconfigyamls- Symfony UX:
#[AsTwigComponent]/#[AsLiveComponent](constructor,mount()),#[LiveProp],#[LiveAction],#[LiveListener], lifecycle hooks
Doctrine:
#[AsEntityListener],#[AsDoctrineListener]attributeDoctrine\ORM\Events::*events,Doctrine\Common\EventSubscribermethodsrepositoryMethodin#[UniqueEntity]attribute- lifecycle event attributes
#[PreFlush],#[PostLoad], ... - enums in
#[Column(enumType: UserStatus::class)]
PHPUnit:
- data provider methods
testXxxmethods- annotations like
@test,@before,@afterClassetc - attributes like
#[Test],#[Before],#[AfterClass]etc
PhpBench:
benchXxxmethods#[BeforeMethods],#[AfterMethods]attributes#[ParamProviders]attribute for param provider methods
Behat:
- context class constructors
- step definitions via annotations (
@Given,@When,@Then) or attributes (#[Given],#[When],#[Then]) - hooks via annotations (
@BeforeScenario,@AfterScenario, etc.) or attributes (#[BeforeScenario],#[AfterScenario], etc.) - transformations via
@Transformor#[Transform]
PHPStan:
- constructor calls for DIC services (rules, extensions, ...)
Nette:
handleXxx,renderXxx,actionXxx,injectXxx,createComponentXxxSmartObjectmagic calls for@propertyannotations
Nette Tester:
test*methods,setUp/tearDown,@dataProvidermethods inTester\TestCasesubclasses
Laravel:
- Route registration —
Route::get/post/put/...(),resource(),apiResource()with callable, string (Controller@method), and invokable syntax - Event listeners —
Event::listen(),Event::subscribe(), auto-discovered listeners (handle*/__invokewith typed first param) - Scheduled jobs —
Schedule::job() - Gates & policies —
Gate::define(),Gate::policy(),$this->authorize()with automatic policy class resolution - Console commands, jobs, service providers, middleware, notifications, form requests, mailables, broadcast events, JSON resources, notifiable routing
Eloquent:
- Model methods — constructor,
boot,booted,casts,newFactory, query scopes, relationships, attribute accessors (modern + legacy) - Observers —
Model::observe()+#[ObservedBy]attribute - Factories (
definition,configure), seeders (run), migrations (up,down)
Twig:
- View objects passed as parameters to twig templates (including transitively referenced ones)
- Passed to
$controller->render('my.twig', ['param' => $viewModel]), - Returned from
#[Template]controller methods - Rendered via
Twig\Environment::render()and similar
- Passed to
#[AsTwigFilter],#[AsTwigFunction],#[AsTwigTest]new TwigFilter(..., callback),new TwigFunction(..., callback),new TwigTest(..., callback)
All those libraries are autoenabled when found within your composer dependencies. If you want to force enable/disable some of them, you can:
parameters:
shipmonkDeadCode:
usageProviders:
phpunit:
enabled: true
Generic usage providers:
Reflection:
- Any property, enum, constant or method accessed via
ReflectionClassis detected as used- e.g.
$reflection->getConstructor(),$reflection->getConstant('NAME'),$reflection->getMethods(),$reflection->getCases()...
- e.g.
Vendor:
- Any overridden method that originates in
vendoris not reported as dead- e.g. implementing
Psr\Log\LoggerInterface::logis automatically considered used
- e.g. implementing
Builtin:
- Any overridden method that originates from PHP core or extensions is not reported as dead
- e.g. implementing
IteratorAggregate::getIteratoris automatically considered used
- e.g. implementing
Enum:
- Detects usages caused by
BackedEnum::from,BackedEnum::tryFromandUnitEnum::cases
StreamWrapper:
- Detects usages caused by
stream_wrapper_register
Those providers are enabled by default, but you can disable them if needed.
Excluding usages in tests:
- By default, all usages within scanned paths can mark members as used
- But that might not be desirable if class declared in
srcis only used intests - You can exclude those usages by enabling
testsusage excluder:- This will not disable analysis for tests as only usages of src-defined classes will be excluded
parameters:
shipmonkDeadCode:
usageExcluders:
tests:
enabled: true
devPaths: # optional, autodetects from autoload-dev sections of composer.json when omitted
- %currentWorkingDirectory%/tests
With such setup, members used only in tests will be reported with corresponding message, e.g:
Unused AddressValidator::isValidPostalCode (all usages excluded by tests excluder)
[!TIP] We recommend enabling this excluder for all projects.
Customization:
- If your application does some magic calls unknown to this library, you can implement your own usage provider.
- Just tag it with
shipmonk.deadCode.memberUsageProviderand implementShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider
services:
-
class: App\ApiOutputUsageProvider
tags:
- shipmonk.deadCode.memberUsageProvider
[!IMPORTANT] The interface & tag changed in 0.7. If you are using PHPStan 1.x, those were used differently.
Reflection-based customization:
- For simple reflection usecases, you can just extend
ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider:
use ReflectionMethod;
use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;
class FuzzyTwigUsageProvider extends ReflectionBasedMemberUsageProvider
{
public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData
{
if ($method->getDeclaringClass()->implementsInterface(UsedInTwigMarkerInterface::class)) {
return VirtualUsageData::withNote('Probably used in twig');
}
return null;
}
}
AST-based customization:
- For more complex usecases that are deducible only from AST, you just stick with raw
MemberUsageProviderinterface. - Here is simplified example how to emit
User::__constructusage in following PHP snippet:
function test(SerializerInterface $serializer): User {
return $serializer->deserialize('{"name": "John"}', User::class, 'json');
}
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use ReflectionMethod;
use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef;
use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage;
use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin;
use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider;
use Symfony\Component\Serializer\SerializerInterface;
class DeserializationUsageProvider implements MemberUsageProvider
{
public function __construct(
private UsageOriginDetector $originDetector,
) {}
/**
* @return list<ClassMemberUsage>
*/
public function getUsages(Node $node, Scope $scope): array
{
if (!$node instanceof MethodCall) {
return [];
}
if (
// our deserialization calls constructor
$scope->getType($node->var)->getObjectClassNames() === [SerializerInterface::class] &&
$node->name->toString() === 'deserialize'
) {
$secondArgument = $node->getArgs()[1]->value;
$serializedClass = $scope->getType($secondArgument)->getConstantStrings()[0];
// record the place it was called from (needed for proper transitive dead code elimination)
$usageOrigin = UsageOrigin::createRegular($node, $scope);
// record the hidden constructor call
$constructorRef = new ClassMethodRef($serializedClass->getValue(), '__construct', possibleDescendant: false);
return [new ClassMethodUsage($usageOrigin, $constructorRef)];
}
return [];
}
}
Excluding usages:
You can exclude any usage based on custom logic, just implement MemberUsageExcluder and register it with shipmonk.deadCode.memberUsageExcluder tag:
use ShipMonk\PHPStan\DeadCode\Excluder\MemberUsageExcluder;
class MyUsageExcluder implements MemberUsageExcluder
{
public function shouldExclude(ClassMemberUsage $usage, Node $node, Scope $scope): bool
{
// ...
}
}
# phpstan.neon.dist
services:
-
class: App\MyUsageExcluder
tags:
- shipmonk.deadCode.memberUsageExcluder
The same interface is used for exclusion of test-only usages, see above.
[!NOTE] Excluders are called after providers.
Dead cycles & transitively dead methods
- This library automatically detects dead cycles and transitively dead methods (methods that are only called from dead methods)
- By default, it reports only the first dead method in the subtree and the rest as a tip:
------ ------------------------------------------------------------------------
Line src/App/Facade/UserFacade.php
------ ------------------------------------------------------------------------
26 Unused App\Facade\UserFacade::updateUserAddress
🪪 shipmonk.deadMethod
💡 Thus App\Entity\User::updateAddress is transitively also unused
💡 Thus App\Entity\Address::setPostalCode is transitively also unused
💡 Thus App\Entity\Address::setCountry is transitively also unused
💡 Thus App\Entity\Address::setStreet is transitively also unused
💡 Thus App\Entity\Address::MAX_STREET_CHARS is transitively also unused
------ ------------------------------------------------------------------------
- If you want to report all dead methods individually, you can enable it in your
phpstan.neon.dist:
parameters:
shipmonkDeadCode:
reportTransitivelyDeadMethodAsSeparateError: true
Automatic removal of dead code
- If you are sure that the reported methods are dead, you can automatically remove them by running PHPStan with
removeDeadCodeerror format:
vendor/bin/phpstan analyse --error-format removeDeadCode
class UserFacade
{
- public const TRANSITIVELY_DEAD = 1;
-
- public function deadMethod(): void
- {
- echo self::TRANSITIVELY_DEAD;
- }
}
- If you are excluding tests usages (see above), this will not cause the related tests to be removed alongside.
- But you will see all those kept usages in output (with links to your IDE if you set up
editorUrlparameter)
- But you will see all those kept usages in output (with links to your IDE if you set up
• Removed method UserFacade::deadMethod
! Excluded usage at tests/User/UserFacadeTest.php:241 left intact
- Also, removing dead properties currently only removes its definition (leaving all write usages as is).
Calls over unknown types
- In order to prevent false positives, we support even calls over unknown types (e.g.
$unknown->method()) by marking all methods namedmethodas used- Such behaviour might not be desired for strictly typed codebases, because e.g. single
new $unknown()will mark all constructors as used - The same applies to constant fetches over unknown types (e.g.
$unknown::CONSTANT) - Thus, you can disable this feature in your
phpstan.neon.distby excluding such usages:
- Such behaviour might not be desired for strictly typed codebases, because e.g. single
parameters:
shipmonkDeadCode:
usageExcluders:
usageOverMixed:
enabled: true
- If you want to check how many of those cases are present in your codebase, you can run PHPStan analysis with
-vvvand you will see some diagnostics:
Found 2 usages over unknown type:
• setCountry method, for example in App\Entity\User::updateAddress
• setStreet method, for example in App\Entity\User::updateAddress
Access of unknown member
- In order to prevent false positives, we support even calls of unknown methods (e.g.
$class->$unknown()) by marking all possible methods as used - If we find unknown call over unknown type (e.g.
$unknownClass->$unknownMethod()), we ignore such usage (as it would mark all methods in codebase as used) and show warning in debug verbosity (-vvv) - Note that some calls over
ReflectionClassalso emit unknown method calls:
/** @var ReflectionClass<Foo> $reflection */
$methods = $reflection->getMethods(); // all Foo methods are used here
- All that applies even to constants and properties.
Limitations:
- Methods of anonymous classes are never reported as dead (PHPStan limitation)
- Abstract trait methods are never reported as dead
- Most magic methods (e.g.
__get,__setetc) are never reported as dead- Only supported are:
__construct,__clone
- Only supported are:
Other problematic cases:
Constructors:
- For symfony apps & PHPStan extensions, we simplify the detection by assuming all DIC classes have used constructor.
- For other apps, you may get false-positives if services are created magically.
- To avoid those, you can easily disable constructor analysis with single ignore:
parameters:
ignoreErrors:
- '#^Unused .*?::__construct$#'
Private constructors:
- Those are never reported as dead as those are often used to deny class instantiation
- This applies only to constructors without any parameters
Serialization:
- Properties/enum cases read only through serialization (e.g. public properties serialized to JSON response) may be reported as
neverRead - Custom serialization/deserialization logic needs to be handled via custom usage providers
- For example, if your API output objects implement a common interface, use simple:
use ReflectionProperty;
use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;
class ApiOutputPropertyUsageProvider extends ReflectionBasedMemberUsageProvider
{
protected function shouldMarkPropertyAsRead(ReflectionProperty $property): ?VirtualUsageData
{
if ($property->getDeclaringClass()->implementsInterface(ApiOutput::class)) {
return VirtualUsageData::withNote('Used upon JSON serialization');
}
return null;
}
}
- If you can detect such usages only by e.g.
Controllerreturn value, use AST-based provider- You can inspire by Twig Provider which does a very similar thing
Interface methods:
- If you never call interface method over the interface, but only over its implementors, it gets reported as dead
- But you may want to keep the interface method to force some unification across implementors
- The easiest way to ignore it is via custom
MemberUsageProvider:
- The easiest way to ignore it is via custom
use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;
class IgnoreDeadInterfaceUsageProvider extends ReflectionBasedMemberUsageProvider
{
public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData
{
if ($method->getDeclaringClass()->isInterface()) {
return VirtualUsageData::withNote('Interface method, kept for unification even when possibly unused');
}
return null;
}
}
Debugging:
- If you want to see how dead code detector evaluated usages of certain member, you do the following:
parameters:
shipmonkDeadCode:
debug:
usagesOf:
- App\User\Entity\Address::__construct
Then, run PHPStan with -vvv CLI option and you will see the output like this:
App\User\Entity\Address::__construct
|
| Marked as alive by:
| entry virtual usage from ShipMonk\PHPStan\DeadCode\Provider\SymfonyUsageProvider
| calls App\User\RegisterUserController::__invoke:36
| calls App\User\UserFacade::registerUser:142
| calls App\User\Entity\Address::__construct
|
| Found 2 usages:
| • src/User/UserFacade.php:142
| • tests/User/Entity/AddressTest.php:64 - excluded by tests excluder
If you set up editorUrl parameter, you can click on the usages to open it in your IDE.
[!TIP] You can change the list of debug references without affecting result cache, so rerun is instant!
Usage in libraries:
- Libraries typically contain public api, that is unused
- If you mark such methods with
@apiphpdoc, those will be considered entrypoints - You can also mark whole class or interface with
@apito mark all its methods as entrypoints
- If you mark such methods with
Running PHPStan partial analysis:
- Dead code detection can be reliable executed only on full codebase, thus it gets autodisabled during partial analysis (when only files are passed to PHPStan analysis)
- In such cases, PHPStan will report
No error with identifier shipmonk.deadMethod is reported on line Xfalse positives for every inline ignore (e.g.// @phpstan-ignore shipmonk.deadMethod) as those errors are no longer emitted - To eliminate those false positives, use built-in formatter that filters out those errors:
- In such cases, PHPStan will report
parameters:
errorFormat: filterOutUnmatchedInlineIgnoresDuringPartialAnalysis
# optionally:
shipmonkDeadCode:
filterOutUnmatchedInlineIgnoresDuringPartialAnalysis:
wrappedErrorFormatter: table
Future scope:
- Dead class detection
- Dead parameters detection
- Useless public/protected visibility
Contributing
- Check your code by
composer check - Autofix coding-style by
composer fix:cs - All functionality must be tested
Supported PHP versions
0.x— PHP 7.4 - 8.51.x— PHP 8.1+