PHPCPD (Drupal)
Goal
PHPCPD reads a codebase's PHP files and reports the blocks that repeat elsewhere almost unchanged — code that could be one function instead of several copies drifting apart. With it installed, a duplication claim about a Drupal codebase can be checked rather than asserted.
The original sebastian/phpcpd is abandoned; Packagist marks it abandoned and
suggests no replacement, and its own README says the repository is kept only for
archival purposes. systemsdk/phpcpd is the actively maintained fork: its
composer.json still lists Sebastian Bergmann as lead author alongside the
maintaining developer, and its README describes it as a continuation of the
abandoned project. Install this fork, not the original.
Install
ddev composer require --dev systemsdk/phpcpd
The package registers no Composer plugin and needs no allow-plugins entry —
unlike drupal/coder and the PHPStan extensions, nothing here loads through the
plugin API. ddev composer runs Composer inside the DDEV web container, so it
resolves the version against the container's PHP: the latest release, 9.1.0,
requires PHP 8.4 or later, while earlier releases in the same fork (8.x)
require PHP 8.3 or later. A CI pipeline that runs Composer without DDEV should
still set config.platform.php to the target PHP version, so resolution
matches what the code will actually run on there too.
Run
ddev exec vendor/bin/phpcpd --version
Prints the installed version and exits 0. If the command is not found, the package is absent: install, then run it again.
PHPCPD scans directories, not individual files — a file named on its command line
produces No files found to scan and exits 1. A real scan names the directories to
check and the suffixes to include, since its default suffix is .php alone:
ddev exec vendor/bin/phpcpd --suffix .php --suffix .module web/modules/custom/my_module
Findings print to stdout as a list of duplicated blocks with their files and line ranges. The exit status is non-zero when a clone was found, so a caller can branch on it without reading the text — the same non-zero status a missing scan target produces, so an empty scope reads as unmet rather than as clean.