reload / dead-code-detector-drupal
Drupal support for shipmonk/dead-code-detector: usage providers that teach it how Drupal calls code.
Package info
github.com/reload/dead-code-detector-drupal
Type:phpstan-extension
pkg:composer/reload/dead-code-detector-drupal
Requires
- php: ^8.3
- composer-runtime-api: ^2.2
- mglaman/phpstan-drupal: ^2.0
- phpstan/phpstan: ^2.0
- shipmonk/dead-code-detector: ^1.0
- symfony/filesystem: ^6.4 || ^7.0 || ^8.0
- symfony/finder: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
- twig/twig: ^3.0
Requires (Dev)
- drupal/core: ^11.3 || ^12
- infection/infection: ^0.35.6
- phpat/phpat: ^0.12.5
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.0
- shipmonk/phpstan-rules: ^4.4
- slevomat/coding-standard: ^8.31
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A dead-code-detector extension that teaches it how Drupal calls code, so it reports dead class members in your custom modules, themes and profiles rather than Drupal's wiring.
Summary
- ✅ PHPStan extension, on top of shipmonk/dead-code-detector
- 🧩 Drupal's wiring understood: services, routes, hooks, plugins, entities, callbacks, Twig templates, Drush
- 📦 Core and contrib treated as library code, wherever Composer puts them
- 🧪 Dead tested code detection for Drupal's
testsdirectories - 🔌 Customizable with usage providers of your own
- ⚙️ No configuration beyond two switches
Installation
Requires:
- Drupal 11.3 and later, PHP 8.3 and later.
- Drupal 10 is not supported. Sites on
drupal/core-devcannot install it, since core-dev pins PHPStan 1 there; others can, untested. - phpstan-drupal comes with it and is required: without it,
PHPStan analyses no
.module,.theme,.installor.incfiles, so code only procedural hooks call looks dead, and it cannot load core modules' and contrib's classes, so overrides of their members look dead too.
composer require --dev reload/dead-code-detector-drupal
Use phpstan/extension-installer or load the extensions yourself:
# phpstan.neon.dist includes: - vendor/mglaman/phpstan-drupal/extension.neon - vendor/shipmonk/dead-code-detector/rules.neon - vendor/reload/dead-code-detector-drupal/extension.neon
Note
Analyse all your custom code: every custom module, theme and profile, tests included. Code outside the analysed paths does not count as library code, its callers are invisible, and overrides of its members can be reported.
Map your custom entity types for phpstan-drupal. The detector only follows
calls whose target PHPStan can type, and phpstan-drupal only knows core's entity
types. For yours, $entity->foo() otherwise keeps every method named foo
alive, or with dead-code-detector's usageOverMixed excluder reports false
positives instead.
parameters: drupal: entityMapping: my_entity: class: Drupal\my_module\Entity\MyEntity
Usage
vendor/bin/phpstan
A module whose Importer service is wired in example_module.services.yml,
with a constant nobody reads, a method nobody calls and a method only a unit
test calls, reports:
------ -----------------------------------------------------------------------
Line web/modules/custom/example_module/src/Importer.php
------ -----------------------------------------------------------------------
11 Unused Drupal\example_module\Importer::BATCH_SIZE
🪪 shipmonk.deadConstant
14 Property Drupal\example_module\Importer::$logger is never read
🪪 shipmonk.deadProperty.neverRead
17 Unused Drupal\example_module\Importer::import
🪪 shipmonk.deadMethod
💡 Thus Drupal\example_module\Importer::normalise is transitively
unused
27 Unused Drupal\example_module\Importer::fromLegacyExport (all usages
excluded by drupalTests excluder)
🪪 shipmonk.deadMethod
------ -----------------------------------------------------------------------
- The constructor is not listed: the services file wires the class, and the container's construction counts.
$loggeris injected and never used, the commonest finding on real sites.normalise()is reached only through the deadimport(), so it goes with it instead of getting a line of its own.fromLegacyExport()has callers, but only undertests, so it can be deleted together with its test.- The identifier after 🪪 is what
@phpstan-ignoretakes.
Supported Drupal mechanisms
Services
*.services.yml: constructors, autowired services included;factory,callsandconfiguratormethods; the methods core calls through theaccess_check,cache.contextandservice_collectortagsgetSubscribedEvents()and#[AsCommand]through dead-code-detector's Symfony support
Routing
*.routing.yml:_controllerasClass::method,service:methodor an invokable class,_title_callback,_custom_access,_formandroute_callbacks, with the constructors of the classes Drupal builds#[Route]on controllers (Drupal 11.4+): the constructor; dead-code-detector counts the routed methods*.permissions.ymlpermission_callbacks- Access checkers'
access()
Hooks
#[Hook]on a method, or on a class withmethod:or__invoke, in modules and themes, with the constructors of hook classes- Hux's
#[Hook],#[Alter]and#[ReplaceOriginalHook] - Procedural hooks need nothing: dead-code-detector does not report functions, and every function body is an entry point
Plugins, entities and validation
- Plugin attribute properties, which
AttributeBase::get()reads - Config entity properties, written on load and read on save
#[ActionMethod]methods of config entities, which recipes call- Constraint properties set from an options array
*.schema.ymlCallbackconstraints
Callbacks
'::method','Class::method','Class:method'and'service:method', written out or built withFoo::class . '::method', in form, render, Ajax and batch callbacks,hook_theme()and field definitions- The constructor too, when Drupal instantiates the class for the call
- A callback counts from where its string appears, so one named only by dead code is dead too.
- Array callables such as
[$this, 'method']are handled by dead-code-detector itself.
Twig templates
- Names that templates under the analysed paths access, limited to the methods Drupal's Twig sandbox lets templates call, with the sandbox lists read from the site's settings files
- See Twig templates for what this covers and misses
Calls by name
- REST resources' HTTP methods:
get(),post(),patch()and the rest - JSON:API resources'
process() - GraphQL data producers'
resolve() - Exception subscribers'
on404()andon4xx()handlers
Drush
- Commands with
#[CLI\Command]or@command - Hooks with
#[CLI\Hook]or@hook - Command class constructors, and
create()factories - Drush 13's
#[AsCommand]commands and#[AsEventListener]listeners, through dead-code-detector's Symfony support
dead-code-detector's own providers, active in any Drupal site
- Symfony event subscribers,
#[Required],#[Route]methods,#[AsMessageHandler]handlers - Twig extension callbacks, PHPUnit tests and data providers, Behat contexts
The coverage map lists Drupal's documented mechanisms one by one, with what covers each and what is not covered.
Library code
- Drupal core, contrib modules and themes, and everything else Composer installs
is library code, wherever Composer put it:
web/core,web/modules/contriborvendor/. dead-code-detector alone treats onlyvendor/that way. - Library code is never reported, and a member that overrides or implements
a library member counts as used: a Views field plugin's
render(), a migrate process plugin'stransform().
Excluding usages in tests
- Usages from a
testsdirectory below an analysed path do not keep code outside tests alive, whether PHP calls, a test module's YAML or a test theme's templates make them. - Test code itself is still analysed.
- Members only tests use are reported with a suffix, as
fromLegacyExport()above:(all usages excluded by drupalTests excluder). - dead-code-detector's own tests excluder works from
autoload-dev, which Drupal sites do not declare; this one replaces it.
Configuration
These are the defaults; your own config overrides them:
parameters: drupalDeadCode: twigTemplates: # Switch off to stop templates keeping members alive. enabled: true testsExcluder: # Switch off to let usages in tests keep code alive. enabled: true
Those two switches are the whole configuration, by design:
- Everything else is read from the site: services, routes, schema and
permissions from the YAML under the analysed paths, and the Twig sandbox's
twig_sandbox_allowed_*lists from the PHP files insites/*/, read statically, with core's defaults as the fallback. Nothing is declared twice, so nothing drifts. - Contrib modules are covered one at a time, each with a provider that knows how that module calls code. A list of attribute or class names could not say that, so there is none to configure.
- Site-specific callers get a provider in the site, or an
@apitag; see customization.
dead-code-detector's own parameters still apply. This package cannot change
their defaults, because extension-installer loads dead-code-detector's rules
after this package's, so switching off a kind of finding is yours to do.
shipmonkDeadCode.usageExcluders.usageOverMixed.enabled is the one most sites
want to decide on.
Customization
Use dead-code-detector's built-in mechanisms when the detector cannot see a caller:
@apion a member marks it and its overrides as used; on a class or interface, the members that type declares and their overrides. It suits objects read by serialisation: graphql-php response objects, DTOs that Symfony's serializer constructs.@phpstan-ignorewith the identifier and a reason handles one finding.- A baseline handles legacy code.
The package ships no attribute of its own for marking code as used, because production code would then depend on a dev-only package.
For a caller this package does not know, such as a contrib module or a
library, write a usage provider: a few lines of PHP, registered in your
PHPStan config. myclabs/php-enum reads its enums' constants by name, so a
site using it adds:
<?php namespace MySite\PHPStan; use ReflectionClassConstant; use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider; use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData; final class MyCLabsEnumUsageProvider extends ReflectionBasedMemberUsageProvider { protected function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData { return $constant->getDeclaringClass()->isSubclassOf('MyCLabs\Enum\Enum') ? VirtualUsageData::withNote('Enum value, read by MyCLabs\Enum\Enum') : null; } }
services: - class: MySite\PHPStan\MyCLabsEnumUsageProvider tags: - shipmonk.deadCode.memberUsageProvider
PHPStan builds its services before it loads bootstrapFiles, so the class
must be autoloadable, for example through Composer's autoload-dev.
Writing a usage provider covers the other shapes a
provider takes, the bases this package offers for members called by name, and
when the mechanism belongs in this package instead.
Limitations
By design
- Wiring is read statically: Drupal is not bootstrapped and no database is
needed (why). Wiring done in PHP, such as a
service provider's
register(), is seen only where it names a method as a string callable. - Templates are matched by name, so a coincidental name keeps a dead member alive, and a sandbox list computed at runtime is not seen. That costs about one hidden finding per site; see Twig templates.
- When unsure, code is used: a call on a value PHPStan cannot type keeps every member of that name alive, because a report is something people act on by deleting code.
- Only custom code is reported. There is no mode for contrib modules, where every public member is potential API, and no reporting of dead functions, which dead-code-detector does not analyse.
- Tests are a directory named
testsbelow an analysed path; a test module elsewhere counts as production code. - This package does not read which extensions a site installs, so a hook or
plugin for a module missing from
core.extension.ymlstill counts as used. On the sites measured, such code was deleted together with the module. Detecting leftovers is not worth the added complexity of reading configuration and mapping each hook to the module that defines it.
Not covered yet
- Constructors reached only through core's
AutowireTrait::create(), such as forms built withgetForm()or validators with injected services, when nothing this package reads names the class. - The rest is in the coverage map.
Callers outside Drupal's wiring
- Symfony's serializer constructing input objects, graphql-php reading response
properties by name, and a
method_exists()check before a call are invisible to dead-code-detector and to this package alike. Mark such classes with@api, or write a provider for the library.
In dead-code-detector
- A property that a trait, the class using it or that class's parent declares again is reported as unused.
- A subclass redeclaring a property with a default does not count as writing it.
Debugging
To see why a member counts as used, or does not, use dead-code-detector's
debugging: list the member under
shipmonkDeadCode.debug.usagesOf and run PHPStan with -vvv. The note of
the provider that marked it is printed with each usage.
Changing a provider's code does not invalidate PHPStan's result cache, so run
vendor/bin/phpstan clear-result-cache after editing one.
Documentation
- Coverage: Drupal's documented mechanisms mapped to the providers, and what is not covered.
- Design: why the code is structured, checked and tested as it is, and what a new provider needs.
- Twig templates: how name matching works, what it misses, and the measurement behind keeping it.
- Writing a usage provider: for custom code, contrib modules and libraries.
- Related work: dead-code-detector, phpstan-drupal, Drupal Canvas and drupal-rector, and what this package takes from each.
- Measurements on dpl-web and Canvas.
Contributing
make checksruns everything CI runs;make helplists the targets.- Commit messages follow Conventional Commits, which
make lint-commitschecks. - Start with the design before changing the code.
Releases
On each push to main, semantic-release reads the new commits:
- A
fixcommit makes a patch release, afeatcommit a minor release, a!after the type or aBREAKING CHANGEfooter a major release, and other types none. - A release creates a Git tag and a GitHub release and updates
CHANGELOG.md; Packagist picks the tag up through its GitHub hook. Versions start below 1.0 from thev0.0.0tag on the first commit. - The release commit is pushed with the
GH_RELEASE_TOKENsecret, a token withContents: Read and writefrom an account on the bypass list of the ruleset onmain.
License
MIT, see LICENSE.