shipmonk/dead-code-detector
PHPStan extension that detects unused PHP code: dead methods, properties, constants, and enum cases. Finds dead cycles and transitive dead members, can flag dead tested code, supports popular frameworks (e.g., Symfony), and offers customizable usage providers with optional auto-removal.
PHPStan extension to find unused PHP code in your project with ease!
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
$ vendor/bin/phpstan
[!NOTE] Make sure you analyse whole codebase (e.g. both
srcandtests) so that all usages are found.
Check out the recording and slides from TechMeetup Conference (2025) about this package:
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
phpstan/phpstan-symfony with containerXmlPathshipmonkDeadCode.usageProviders.symfony.containerXmlPaths configured#[AsEventListener], #[AsMessageHandler], #[AsController], #[AsCommand]#[Assert\Callback], #[Interact], #[Route], #[Required] (methods and properties)#[AsSchedule], #[AsCronTask], #[AsPeriodicTask]#[AutoconfigureTag('doctrine.event_listener')], #[Autoconfigure(constructor:)], #[Autoconfigure(calls:)], #[AutowireCallable]defaultIndexMethod and defaultPriorityMethod in #[AutowireLocator], #[AutowireIterator], #[TaggedIterator], #[TaggedLocator]#[AsAnnounceListener], ...EventSubscriberInterface::getSubscribedEventsonKernelResponse, onKernelRequest, etc!php/const and !php/enum references in config yamls#[AsTwigComponent]/#[AsLiveComponent] (constructor, mount()), #[LiveProp], #[LiveAction], #[LiveListener], lifecycle hooks#[AsEntityListener], #[AsDoctrineListener] attributeDoctrine\ORM\Events::* events, Doctrine\Common\EventSubscriber methodsrepositoryMethod in #[UniqueEntity] attribute#[PreFlush], #[PostLoad], ...#[Column(enumType: UserStatus::class)]testXxx methods@test, @before, @afterClass etc#[Test], #[Before], #[AfterClass] etcbenchXxx methods#[BeforeMethods], #[AfterMethods] attributes#[ParamProviders] attribute for param provider methods@Given, @When, @Then) or attributes (#[Given], #[When], #[Then])@BeforeScenario, @AfterScenario, etc.) or attributes (#[BeforeScenario], #[AfterScenario], etc.)@Transform or #[Transform]handleXxx, renderXxx, actionXxx, injectXxx, createComponentXxxSmartObject magic calls for @property annotationstest* methods, setUp/tearDown, @dataProvider methods in Tester\TestCase subclassesRoute::get/post/put/...(), resource(), apiResource() with callable, string (Controller@method), and invokable syntaxEvent::listen(), Event::subscribe(), auto-discovered listeners (handle*/__invoke with typed first param)Schedule::job()Gate::define(), Gate::policy(), $this->authorize() with automatic policy class resolutionboot, booted, casts, newFactory, query scopes, relationships, attribute accessors (modern + legacy)Model::observe() + #[ObservedBy] attributedefinition, configure), seeders (run), migrations (up, down)$controller->render('my.twig', ['param' => $viewModel]),#[Template] controller methodsTwig\Environment::render() and similar#[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
ReflectionClass is detected as used
$reflection->getConstructor(), $reflection->getConstant('NAME'), $reflection->getMethods(), $reflection->getCases()...vendor is not reported as dead
Psr\Log\LoggerInterface::log is automatically considered usedIteratorAggregate::getIterator is automatically considered usedBackedEnum::from, BackedEnum::tryFrom and UnitEnum::casesstream_wrapper_registerThose providers are enabled by default, but you can disable them if needed.
src is only used in teststests usage excluder:
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.
shipmonk.deadCode.memberUsageProvider and implement ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProviderservices:
-
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.
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;
}
}
MemberUsageProvider interface.User::__construct usage 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 [];
}
}
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.
------ ------------------------------------------------------------------------
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
------ ------------------------------------------------------------------------
phpstan.neon.dist:parameters:
shipmonkDeadCode:
reportTransitivelyDeadMethodAsSeparateError: true
removeDeadCode error format:vendor/bin/phpstan analyse --error-format removeDeadCode
class UserFacade
{
- public const TRANSITIVELY_DEAD = 1;
-
- public function deadMethod(): void
- {
- echo self::TRANSITIVELY_DEAD;
- }
}
editorUrl parameter) • Removed method UserFacade::deadMethod
! Excluded usage at tests/User/UserFacadeTest.php:241 left intact
$unknown->method()) by marking all methods named method as used
new $unknown() will mark all constructors as used$unknown::CONSTANT)phpstan.neon.dist by excluding such usages:parameters:
shipmonkDeadCode:
usageExcluders:
usageOverMixed:
enabled: true
-vvv and 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
$class->$unknown()) by marking all possible methods as used$unknownClass->$unknownMethod()), we ignore such usage (as it would mark all methods in codebase as used) and show warning in debug verbosity (-vvv)ReflectionClass also emit unknown method calls:/** @var ReflectionClass<Foo> $reflection */
$methods = $reflection->getMethods(); // all Foo methods are used here
__get, __set etc) are never reported as dead
__construct, __cloneparameters:
ignoreErrors:
- '#^Unused .*?::__construct$#'
neverReaduse 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;
}
}
Controller return value, use AST-based provider
MemberUsageProvider: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;
}
}
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!
@api phpdoc, those will be considered entrypoints@api to mark all its methods as entrypointsNo error with identifier shipmonk.deadMethod is reported on line X false positives for every inline ignore (e.g. // @phpstan-ignore shipmonk.deadMethod) as those errors are no longer emittedparameters:
errorFormat: filterOutUnmatchedInlineIgnoresDuringPartialAnalysis
# optionally:
shipmonkDeadCode:
filterOutUnmatchedInlineIgnoresDuringPartialAnalysis:
wrappedErrorFormatter: table
composer checkcomposer fix:cs0.x — PHP 7.4 - 8.51.x — PHP 8.1+How can I help you explore Laravel packages today?