PHPStan extension to find unused PHP code in your project with ease!
- β PHPStan extension
- β»οΈ Dead cycles detection
- π Transitive dead member detection
- π§ͺ Dead tested code detection
- π§Ή Automatic removal of unused code
- π Popular libraries support
- β¨ Customizable usage providers
composer require --dev shipmonk/dead-code-detectorUse official extension-installer or just load the rules:
# phpstan.neon.dist
includes:
- vendor/shipmonk/dead-code-detector/rules.neon$ vendor/bin/phpstanNote
Make sure you analyse whole codebase (e.g. both src and tests) 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- Calls made by DIC over your services!
- constructors, calls, factory methods
phpstan/phpstan-symfonywithcontainerXmlPathmust be used
#[AsEventListener]attribute#[AsMessageHandler]attribute#[AsController]attribute#[AsCommand]attribute#[Interact]attribute#[Required]attribute#[Route]attributes#[Assert\Callback]attributesEventSubscriberInterface::getSubscribedEventsonKernelResponse,onKernelRequest, etc!php constreferences inconfigyamlsdefaultIndexMethodanddefaultPriorityMethodin#[AutowireLocator]and#[AutowireIterator]- Workflow event listener attributes:
#[AsAnnounceListener], ... #[AutoconfigureTag('doctrine.event_listener')]attribute
#[AsEntityListener]attribute#[AsDoctrineListener]attributeDoctrine\ORM\Events::*eventsDoctrine\Common\EventSubscribermethodsrepositoryMethodin#[UniqueEntity]attribute- lifecycle event attributes
#[PreFlush],#[PostLoad], ... - enums in
#[Column(enumType: UserStatus::class)]
- data provider methods
testXxxmethods- annotations like
@test,@before,@afterClassetc - attributes like
#[Test],#[Before],#[AfterClass]etc
benchXxxmethods#[BeforeMethods],#[AfterMethods]attributes#[ParamProviders]attribute for param provider methods
- 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]
- constructor calls for DIC services (rules, extensions, ...)
handleXxx,renderXxx,actionXxx,injectXxx,createComponentXxxSmartObjectmagic calls for@propertyannotations
test*methods,setUp/tearDown,@dataProvidermethods inTester\TestCasesubclasses
- 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- 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.
- Any overridden method that originates in
vendoris not reported as dead- e.g. implementing
Psr\Log\LoggerInterface::logis automatically considered used
- e.g. implementing
- 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
- Detects usages caused by
BackedEnum::from,BackedEnum::tryFromandUnitEnum::cases
- Detects usages caused by
stream_wrapper_register
Those providers are enabled by default, but you can disable them if needed.
- 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%/testsWith 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.
- 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.memberUsageProviderImportant
The interface & tag changed in 0.7. If you are using PHPStan 1.x, those were used differently.
- 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;
}
}- 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', 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.memberUsageExcluderThe same interface is used for exclusion of test-only usages, see above.
Note
Excluders are called after providers.
- 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- 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 removeDeadCodeclass 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).
- 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
- 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.
- 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:
- 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$#'- Those are never reported as dead as those are often used to deny class instantiation
- This applies only to constructors without any parameters
- 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
- 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;
}
}- 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::__constructThen, 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 excluderIf 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!
- 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
- 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- Dead class detection
- Dead parameters detection
- Useless public/protected visibility
- Check your code by
composer check - Autofix coding-style by
composer fix:cs - All functionality must be tested
- PHP 7.4 - 8.5