Skip to content

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.