Token-based copy/paste detection for PHP 8.5+ — a maintained successor to phpcpd, with reorder-tolerant (Type-3) detection.
A maintained, dependency-free successor to the archived
sebastianbergmann/phpcpd. It finds duplicated
code — and, unlike most copy/paste detectors, it ships three complementary detection engines
so it can see exact copies, reordered clones, and gapped near-misses.
Drop-in replacement: the command is still
phpcpd. Out of the box it runs Rabin-Karp + TokenBag (exact and reordered duplication); the classic Rabin-Karp-only behaviour is one flag (--rk) away. The deeper research engines are opt-in.
- Three token-based engines — Rabin-Karp (exact) and TokenBag (reordered) run by default; the suffix tree (gapped Type-3) is an opt-in research engine.
- Orphan detection (
--orphans) — find unreferenced classes, interfaces, traits, enums, and functions, with four result tiers, structural suppression rules, and framework entry-point awareness. - Generated and cache trees excluded by default — a tool cache is a dense index of the very identifiers an orphan scan searches for, so scanning one can turn a failing gate green.
- Suppression you can audit — deliberate duplication is marked in comments, and a suppressed symbol is still counted and listed on demand rather than silently dropped.
phpcpd.ini— per-project settings whose keys are the long option names, with--show-configto see what is in force and which layer set it.- Actionable console output — every clone comes with a context-aware refactoring hint, plus a run summary (duplicated-line percentage, average and largest clone size).
- Inconsistent-clone reporting — with
--algorithm=suffixtree, diverged near-misses are flagged[inconsistent](the bug-prone kind: one copy patched, its sibling not). - Four output formats — human-readable console, PMD-CPD XML, JSON, and SARIF 2.1.0.
- CI-ready — meaningful exit codes, result caching, and a per-file incremental index.
- Framework presets —
--preset=laravel(and an extensible preset format). - Headless API + PHPUnit integration — embed detection in tests or tools, no shelling out.
- PHP 8.5+, zero runtime dependencies, deterministic — same input, same result, every run.
The common wisdom is that phpcpd only finds Type-1/2 (exact / renamed) clones. That was only ever true of its default engine. phpcpd-next exposes and extends the full picture:
| Clone type | Example | Engine | Availability |
|---|---|---|---|
| Type-1 exact | identical code | rabin-karp |
default |
| Type-3 reordered | statements shuffled within a function | tokenbag |
default |
| Type-3 gapped | a statement inserted/deleted/changed | suffixtree |
advanced (--algorithm) |
| Type-2 renamed | same code, different identifiers | any engine | advanced (--fuzzy) |
The two default engines run together on every phpcpd <dir> invocation. The Type-3-gapped and
Type-2 capabilities are research-grade and opt-in (see Advanced engines).
The suffix-tree engine additionally flags inconsistent clones — near-miss copies that have diverged — which is where duplication tends to hide bugs (one copy patched, its sibling not).
- PHP 8.5+
- ext-dom, ext-mbstring
Zero Composer dependencies. Nothing from the PHPUnit/sebastian release train at runtime.
Install as a dev dependency from Packagist:
composer require --dev phpcpd-next/phpcpdThis installs the phpcpd binary to vendor/bin/phpcpd:
vendor/bin/phpcpd --version
vendor/bin/phpcpd src/Or run it without adding it to your project, via Composer's global bin or a one-off:
composer global require phpcpd-next/phpcpd # then: ~/.composer/vendor/bin/phpcpdFrom source:
git clone https://github.com/phpcpd-next/phpcpd.git
cd phpcpd && composer install
./phpcpd --versionRequires PHP 8.5+ with
ext-domandext-mbstring. Zero runtime dependencies — nothing from the PHPUnit/sebastian release train is pulled in.
# default: exact (Type-1) + reordered (Type-3) duplication, run together
phpcpd src/
# Rabin-Karp only — exact clones, faster, no reorder detection
phpcpd --rk src/
# scan a directory with a framework preset (sensible paths + excludes)
phpcpd --preset=laravel
# write a machine-readable report
phpcpd --log-sarif=phpcpd.sarif src/phpcpd-next exits with status 1 when clones are found (or on error) and 0 when none are — so it works as a CI gate out of the box.
Found 2 code clones with 21 duplicated lines in 2 files:
- app/Services/Billing.php:12-33 (21 lines)
app/Services/Invoicing.php:40-61
→ Consider extracting the shared lines into a reusable method or constant.
- tests/UserTest.php:8-19 (11 lines)
tests/AdminTest.php:8-19
→ Duplicate test scaffold — consider a shared base class or a @dataProvider.
37.50% duplicated lines out of 56 total lines of code.
Average code clone size is 10 lines, the largest code clone has 21 lines
Each clone is followed by a context-aware refactoring hint — the suggestion adapts to the clone
(test scaffolding, a large block, a diverged near-miss, or a plain extract). The closing summary
reports the duplicated-line percentage and the average/largest clone size. Add --verbose to
print the duplicated source itself.
Every phpcpd <dir> run executes two engines and merges their results:
- Rabin-Karp — exact contiguous duplication via a rolling hash. Fast; the classic phpcpd behaviour for Type-1 clones.
- TokenBag — order-invariant overlap (a SourcererCC-style token bag + inverted index). Catches clones where statements were reordered within a function — which contiguous matching cannot.
They are complementary: Rabin-Karp is precise about structure; the token bag tolerates shuffling.
Pass --rk to run Rabin-Karp alone (faster, no reorder detection).
These are opt-in via the (hidden) --algorithm flag and its tuning knobs. They are research-grade
— powerful on the right corpus, but with higher false-positive rates on real-world code, which is
why they are not in the default set or in --help.
--algorithm=suffixtree— approximate matching with a configurable edit budget (--edit-distance, default 5). Detects gapped (Type-3) clones where statements were inserted, deleted, or changed, and marks diverged copies as[inconsistent]. Type-aware: a changed control keyword (if→while) costs more of the edit budget than a renamed identifier. Tune the exact-match prefix with--head-equality(default 10).--algorithm=tokenbag— run the token bag alone (rather than merged with Rabin-Karp). Tune the overlap threshold with--min-similarity(default 0.7).--algorithm=rabin-karp— explicit single-engine Rabin-Karp (equivalent to--rk).--fuzzy— rename-insensitive (Type-2) matching: identifiers and literals are abstracted to type classes.--type-anchoredis the same but preserves type keywords.
phpcpd --algorithm=suffixtree --edit-distance=8 src/ # gapped clones, wider budget
phpcpd --fuzzy src/ # renamed-identifier clonesClones are duplicated code; orphans are unreachable code — a class, interface, trait, enum, or global function that nothing references. Same token engine, no parser, no AST, no runtime dependency.
Two modes:
- Default run — orphans ride along with the clone scan as an advisory: they're reported, but only clones set the exit code. (Safe for framework-heavy projects where dynamic dispatch causes false positives — see the Laravel note below.)
--orphans— orphans only, and they gate CI: a definite orphan makes the run exit non-zero, exactly like a clone.
phpcpd --orphans src/Found 1 orphaned symbol(s):
- Class App\Legacy\UnusedReport
src/Legacy/UnusedReport.php:14
→ never referenced
⤷ whole file is unwired — no symbol declared here is referenced
- Class App\Billing\InvoiceLegacy
src/Billing/Ledger.php:120
→ never referenced
⤷ looks like a superseded copy of App\Billing\Invoice (src/Billing/Ledger.php:14)
Found 2 possible orphan(s) — review before removing:
- Interface App\Contract\PaymentGateway
src/Contract/PaymentGateway.php:9
→ never referenced (interface — may be implemented outside the scanned set)
- Class App\Support\Dynamic
src/Support/Dynamic.php:11
→ never referenced in code; name appears in a string literal (possible dynamic use)
⤷ name appears at src/Support/Registry.php:23
Planned, not yet wired — 1
- Class App\Console\Components\Spinner
src/Console/Components/Spinner.php:39
→ Wired by the console rework.
Suppressed (30): conditional 12 · fixtures 9 · config 8 · namespace 1
→ --explain to list them
829 symbols scanned in 764 files; 2 orphaned, 2 possible, 30 suppressed, 1 planned.
Findings are grouped by cause rather than listed flat, so the group worth reading — the symbols nothing explains — is visible instead of buried. A string-literal demotion cites where the name appears, which is the whole verification for most entries; without it every demotion costs a grep.
Each finding is explained, not just listed:
whole file is unwired— every symbol in that file is an orphan, so the whole file is dead (phpunused's "unreferenced file", at symbol granularity).looks like a superseded copy of X— the orphan's body duplicates a live symbol, so it's almost certainly the stale copy a refactor replaced and forgot to delete. This reuses the clone engine — the orphan × clone synergy that a pure dead-code linter can't offer.
Four result tiers, so the tool never nags you into deleting live code:
| Tier | Meaning | Gates CI |
|---|---|---|
| Definite | Referenced nowhere; safe to delete. | Yes (default) |
| Possible | A contract (interface / abstract / trait) an out-of-tree package may implement, extend, or use, or a name that appears only in a string literal (a candidate for new $class / a DI-container id). |
No |
| Suppressed | A rule structurally accounts for it (see below). Counted and listed on request. | No |
| Planned | Marked @phpcpd-planned: knowingly written ahead of the code that will wire it. |
No |
Widen the gate with --fail-on=dead,planned — shipping a release with staged, unwired components
should be a decision someone makes on purpose.
A symbol nothing references is not automatically a finding. Each rule below recognises a structural reason the symbol is fine — a guard statement, a namespace the project doesn't own, a manifest entry, a fixture path — never a name pattern or a guess about intent.
| Rule | Recognises |
|---|---|
conditional |
Declared inside if (!function_exists('x')) / class_exists / interface_exists / trait_exists / enum_exists — a polyfill or compatibility shim, by definition declared for a caller this scan cannot see. |
namespace |
Declared outside every psr-4/psr-0 prefix the project's own composer.json declares — code published under another package's namespace so someone else's call resolves to it. |
manifest |
Named in composer.json: an autoload.files entry point, or an FQN under extra (Laravel providers and aliases, and anything shaped like a class name). |
config |
Named in a .neon, .yaml, .yml, .xml, or .dist file — PHPStan rules, Symfony DI, Doctrine mapping. |
fixtures |
Under a Fixtures/Stubs directory inside a test tree. Being unreferenced is what makes a fixture a fixture. |
keep |
Carries @api, @psalm-api, @phpstan-api, @phpcpd-keep, or @phpcpd-ignore-orphan. |
entrypoint |
Wired reflectively: #[Route], #[AsCommand], #[AsEventListener], #[AsMessageHandler], #[Entity], #[Attribute] and more, or a *Test class. |
planned |
Carries @phpcpd-planned. |
Suppressed does not mean hidden. The count is always printed, broken down by rule; --explain
lists the symbols. That is deliberate — a rule that starts over-firing shows up as a number that
moved, whereas a silent suppression would quietly turn a real orphan into no output at all.
Suppressed (30): conditional 12 · fixtures 9 · config 8 · namespace 1
→ --explain to list them
Turn any rule off by name to audit it — the symbols are then judged normally rather than skipped:
phpcpd --orphans --no-suppress=fixtures,config src/
phpcpd --orphans --no-suppress=all src/ # raw, unfilteredTwo docblock tags, making opposite claims — do not use one for the other:
/** @phpcpd-keep Registered in phpstan/extension.neon */ // "this IS reachable, you just can't see it"
/** @phpcpd-planned Wired by the console rework. */ // "this is NOT wired yet, and that's known"Both take a free-text reason, which is printed next to the symbol so the next reader learns why
without re-deriving it — and so a suppression whose stated reason has gone stale becomes reviewable.
A @phpcpd-planned symbol that later does get referenced is reported as a possible finding, since
the tag has served its purpose and should be deleted; @phpcpd-keep can never give that prompt.
Tags must start a docblock line. Prose such as Unlike @api classes, this one is internal does not
suppress anything.
Scope, honestly. Orphan detection stops at the type/function level — the "unreferenced file" case.
Method- and property-level dead code needs whole-program type inference (which class does
$this->handle() resolve to under inheritance and a DI container?); that is PHPStan + Psalm's job, and
this token-based tool deliberately does not guess at it. What it does do — decide whether a named
type or function is ever mentioned at all — it does safely: reference detection is generous by design,
so it prefers to stay silent over flagging something that is used. Point it at a whole project
(including bin/, entry scripts, and config) so legitimate roots are seen as referenced.
Laravel and other convention-driven frameworks. Treat orphan output as review candidates, not a
delete list. Laravel reaches many classes with no by-name reference, and they fall into three buckets:
[Controller::class, 'method'] routes, $listen/$subscribe arrays and app(Foo::class) all use
::class, which counts as a real reference; string-based references (string route actions, class
names in config/, container bindings) are demoted to possible — as long as you scan those files
too (routes/, config/); but convention/auto-discovery (policies, Livewire/Filament components,
commands loaded via load(), model observers) leaves classes with no textual mention at all, and those
will false-positive. This is exactly why orphans are advisory in the default run — scan the whole
app, lean on the possible tier, and reach for --orphans (the gating mode) on code you control.
Embed it the same way as clone detection:
use LucianoPereira\PhpcpdNext\Orphans;
$result = Orphans::detect('src');
if ($result->hasDefiniteOrphans()) {
foreach ($result->definite() as $orphan) {
echo $orphan->symbol->fqn, ' — ', $orphan->reason, PHP_EOL;
}
}
// Accounted-for symbols are kept, not dropped — inspect or audit them:
foreach ($result->suppressed() as $entry) {
echo $entry->symbol->fqn, ' suppressed by ', $entry->rule, PHP_EOL;
}
foreach ($result->planned() as $entry) {
echo $entry->symbol->fqn, ' — ', $entry->reason, PHP_EOL; // the staged-work backlog
}
// Turn a rule off to see what it was accounting for.
$audited = Orphans::detect('src', noSuppress: ['fixtures']);Some duplication is correct design. A visitor dispatch table — one match arm per node type,
repeated once per renderer — is parallel on purpose, and folding it into a class => method lookup
would cost both type safety and the compile-visible default => throw that catches an unhandled
case. Three notations say so:
// phpcpd-ignore-start
... deliberately parallel code ...
// phpcpd-ignore-end
/** @phpcpd-ignore-clone Dispatch table — one arm per block type, by design. */
private function block(Block $block): string { ... }
$x = $y; // phpcpd-ignore-lineA clone is dropped when any of its copies intersects a suppressed range — marking one side is a statement about the duplication itself, not about one participant. Region markers matter most, because a clone is a range and frequently corresponds to no single declaration. Markers are read only from files that actually took part in a clone, so an unmarked codebase pays nothing.
Previously the only remedy was --exclude on the whole file, which also hid the duplication worth
fixing.
Settings live in a phpcpd.ini whose keys are the long option names — whatever --help
documents is what you write down, so there is no second vocabulary to learn and an option is
configurable the day it ships.
; phpcpd.ini
min-tokens = 60
exclude[] = build
exclude[] = "*.blade.php"
orphans = true
no-suppress = fixtures
fail-on = dead,plannedThe file is found from the paths being scanned, not from where you typed the command, so
phpcpd ../other-project/src picks up that project's settings rather than your shell's.
Settings are layered, each overriding the last:
built-in defaults → ~/.config/phpcpd/phpcpd.ini → project phpcpd.ini → command line
A key the project file doesn't set keeps whatever the user config gave it; a key neither sets keeps
the built-in default — so a project file states only its differences. Single-valued keys
(min-tokens) are replaced by the closer layer; repeatable ones (exclude, suffix)
append, because a project adding one exclude means "and also this", not "forget the others".
Use --config <file> to name a file explicitly, or --no-config to ignore all of them. Unknown keys
and invalid values are rejected by name, exactly as the equivalent flag would be.
Layering is only trustworthy if you can inspect it, and a setting no file mentions keeps a default
that appears in no file at all. --show-config prints the resolved settings and names the layer that
produced each one:
phpcpd --show-config src/ Layers, lowest precedence first:
built-in defaults
project /home/you/app/phpcpd.ini
command line
SETTING VALUE SOURCE
suffix .php (*) default
exclude ignored project
orphans true project
no-suppress — (*) default
fail-on dead (*) default
min-lines 5 (*) default
min-tokens 12 command line
...
(*) falling back to the built-in default
A repeatable setting names every layer that contributed (build, extra → project + command line),
since those append rather than replace. Research flags stay out of the table until one is set.
Generated and cached trees are skipped by default:
vendor, node_modules, .git,
.phpstan.cache, .phpunit.cache, .php-cs-fixer.cache, .psalm-cache, .rector.cache,
var/cache, storage/framework, bootstrap/cache,
build, dist, out, coverage
vendor and node_modules earn their place on cost alone. The cache directories earn it on
correctness: a static-analysis result cache embeds the fully-qualified name of every class it
analysed as a string literal, which satisfies an orphan scan's reference check for symbols that are
genuinely unreferenced. Pointing phpcpd at a project root — the obvious thing to do — could
therefore produce a passing gate that passes for the wrong reason, with nothing in the output
hinting at it.
These defaults match whole path segments, unlike --exclude, which is substring-based: a default
named out prunes a directory called out/, never routes/. A file whose first 2 KB contains
@generated, Do not edit, or Auto-generated is skipped too, wherever it lives.
Every run states its scope, so a contaminated one is obvious at a glance:
Scanned 764 files (3 directories, 15 exclude patterns applied).
Turn all of it off with --no-default-excludes.
The console report is human-readable and always printed; add --verbose to print the duplicated
source of each clone. Machine-readable reports are written to a file in parallel:
| Format | Flag | For |
|---|---|---|
| Console (text) | (default) | humans; add --verbose for the duplicated snippet |
| PMD-CPD XML | --log-pmd=<file> |
Jenkins, SonarQube, and other PMD-CPD consumers |
| JSON | --log-json=<file> |
scripts and custom dashboards (tool, version, summary, clones[]) |
| SARIF 2.1.0 | --log-sarif=<file> |
GitHub Code Scanning / the Security tab |
phpcpd --log-pmd=report.xml --log-json=report.json --log-sarif=report.sarif src/You can request several at once. SARIF maps inconsistent clones to warning and exact clones
to note, so the bug-bearing duplication surfaces at a higher severity.
- name: Detect duplicated code
run: vendor/bin/phpcpd --log-sarif=phpcpd.sarif src/ || true
- name: Upload results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: phpcpd.sarifClones then appear as annotations in the PR and in the repository's Security tab. (Swap in
--algorithm=suffixtree if you also want gapped/inconsistent clones surfaced.)
The full set shown by phpcpd --help:
Options for selecting files:
--suffix <suffix> Include files ending in <suffix> (default: .php; repeatable)
--exclude <path> Exclude paths (substring or glob, e.g. '*.blade.php'; repeatable)
--preset <name> Apply a framework preset (e.g. laravel): paths, suffixes, excludes
--no-default-excludes Also scan generated/cache trees (skipped by default)
Orphan detection (dead code):
--orphans Detect orphaned (unreferenced) symbols instead of clones
--no-suppress <rules> Turn off suppression rules by name, comma-separated, or 'all'
--fail-on <tiers> Tiers that exit non-zero, comma-separated (default: dead)
--explain List every suppressed symbol instead of only counting them
Options for analysing files:
--rk Rabin-Karp only (exact/Type-1; faster, no reorder detection)
--min-lines <N> Minimum identical lines (default: 5)
--min-tokens <N> Minimum identical tokens (default: 70)
--verbose Print the duplicated code for each clone
Options for report generation:
--log-pmd <file> PMD-CPD XML
--log-json <file> JSON
--log-sarif <file> SARIF 2.1.0 (GitHub Code Scanning)
Options for CI integration:
--cache Cache results in '.phpcpd-cache/' for faster re-runs
--cache-dir <path> Cache directory (implies --cache; overrides default)
--incremental Per-file index: re-tokenize only changed files (rabin-karp)
General:
--config <file> Read settings from <file> (default: ./phpcpd.ini when present)
--no-config Ignore phpcpd.ini
--show-config Print the settings in force, where each came from, and exit
-h, --help Print help
-v, --version Print version
Parsed but hidden from --help — research-grade, see Advanced engines:
--algorithm <name> 'rabin-karp', 'suffixtree', or 'tokenbag' (single-engine override)
--fuzzy Rename-insensitive (Type-2) matching
--type-anchored Like --fuzzy but preserves type keywords
--edit-distance <N> Edit budget (suffixtree only; default: 5)
--head-equality <N> Exact-match prefix length (suffixtree only; default: 10)
--min-similarity <0-1> Minimum token-bag overlap (tokenbag only; default: 0.7)
A preset is a named bundle of sensible defaults — scan paths, file suffixes, and exclude patterns — for a given framework. It is pure configuration: no runtime dependency, no change to how detection works, so it stays faithful to the zero-dependency, deterministic core. Presets exist because every framework has predictable noise (generated caches, scaffolded CRUD, migration boilerplate) that buries real findings; a preset encodes that knowledge once.
# Scans app/ routes/ database/ config/ and skips vendor, storage,
# bootstrap/cache, public, Blade views, and migration boilerplate.
phpcpd --preset=laravelExplicit flags always win: a preset seeds the defaults, then --exclude and
--suffix append to it and --min-lines / --min-tokens override it. Passing a
directory overrides the preset's default paths (its excludes still apply):
phpcpd --preset=laravel app/Services --min-tokens=60 --exclude=app/Generated| Preset | Scans | Skips |
|---|---|---|
laravel |
app routes database config |
vendor, node_modules, storage, bootstrap/cache, public, *.blade.php, database/migrations, IDE-helper files |
Presets are declared in one place (src/Presets.php); adding a framework is a single
Preset entry that the CLI, --help, and the headless API all pick up.
There is no Laravel runtime dependency in phpcpd-next, and there does not need to be —
--preset=laravel is the integration. If you want php artisan ergonomics, a few
lines in your app wire the headless API (below) into a command; no extra package
required:
// app/Console/Commands/CheckDuplication.php
use Illuminate\Console\Command;
use LucianoPereira\PhpcpdNext\Phpcpd;
final class CheckDuplication extends Command
{
protected $signature = 'duplication:check {--min-tokens=70}';
protected $description = 'Detect copy/paste duplication in the application code';
public function handle(): int
{
$clones = Phpcpd::detect(preset: 'laravel', minTokens: (int) $this->option('min-tokens'));
foreach ($clones as $clone) {
$this->warn(sprintf('%d lines duplicated:', $clone->numberOfLines()));
foreach ($clone->files() as $file) {
$this->line(" {$file->name()}:{$file->startLine()}");
}
}
return $clones->count() === 0 ? self::SUCCESS : self::FAILURE;
}
}Tools that want clone detection in-process — a PHPUnit assertion, an Artisan command,
a custom CI script — call the headless API instead of shelling out to the binary.
It finds files, runs the same engine the CLI uses, and returns the raw
CodeCloneMap; there is no banner, no argv parsing, and no file I/O, so it is safe to
call repeatedly in one process.
use LucianoPereira\PhpcpdNext\Phpcpd;
$clones = Phpcpd::detect(
paths: 'app', // string or list of directories
minTokens: 60,
algorithm: null, // null = Rabin-Karp + TokenBag; or 'suffixtree' / 'tokenbag'
preset: 'laravel', // optional; seeds paths/suffixes/excludes
);
foreach ($clones as $clone) {
// $clone->numberOfLines(), $clone->files(), $clone->isGapped(), $clone->toArray()
}
echo $clones->count(), " clones\n";Make duplication a regression test: a clone introduced in a pull request turns the build red, with the offending locations printed in the failure message. Drop in the shipped trait:
use LucianoPereira\PhpcpdNext\PHPUnit\AssertNoDuplication;
use PHPUnit\Framework\TestCase;
final class DuplicationTest extends TestCase
{
use AssertNoDuplication;
public function test_app_is_dry(): void
{
$this->assertNoDuplication(__DIR__ . '/../app', minTokens: 70);
// or, with a preset: $this->assertNoDuplication(preset: 'laravel');
}
}On failure:
Failed asserting that the scanned code contains no duplicated code.
2 clones found:
18 lines @ app/Services/Billing.php:42 ↔ app/Services/Invoicing.php:71
[inconsistent] 24 lines @ app/Http/Controllers/UserController.php:90 ↔ app/Http/Controllers/AdminController.php:88
The trait and the underlying DuplicationConstraint live in
integration/phpunit/, autoloaded under
LucianoPereira\PhpcpdNext\PHPUnit\ once phpcpd-next is a require-dev of your
project. phpcpd-next dogfoods it: its own tests/SelfDryTest.php uses this exact
trait to keep src/ duplication-free across all three engines.
--cache stores the run's results keyed by a fingerprint of the configuration and a manifest of
every scanned file's hash. On a re-run with the same files and config, detection is skipped
entirely and the cached result is replayed (the run prints (cache hit)). Any changed, added, or
removed file is a miss and triggers a full re-scan. Different algorithm/threshold combinations get
separate cache entries, so they never collide.
Mount the cache directory with actions/cache to carry it between CI runs:
- uses: actions/cache@v4
with:
path: .phpcpd-cache
key: phpcpd-${{ hashFiles('**/*.php') }}
restore-keys: phpcpd-
- run: ./phpcpd --cache-dir .phpcpd-cache src/--cache is all-or-nothing: a single changed file invalidates the whole run. --incremental
(Rabin–Karp only) is finer-grained — it persists each file's tokenization keyed by a content hash,
and on a re-run re-tokenizes only the files that changed, replaying the rest straight from the
index. The run prints what it did, e.g. (incremental index: 412 reused, 3 scanned).
The result is identical to a full scan — only the work differs — so it stays correct as files come
and go between runs. Use it on large codebases where most files are untouched between CI runs; mount
the same .phpcpd-cache directory with actions/cache as above. (Requested with another algorithm,
the flag is ignored and the run falls back to the coarse --cache.)
- run: ./phpcpd --incremental --cache-dir .phpcpd-cache src/phpcpd-next is a fork of sebastianbergmann/phpcpd, created by Sebastian Bergmann and archived in
2023. The original copyright is retained throughout; this fork is maintained by Luciano Federico
Pereira. Licensed under BSD-3-Clause — see LICENSE.
The diff-by-diff story of the modernisation and the new detection capabilities lives in MODERNIZATION.md; the research grounding is in the paper. Contributions are welcome under the Contributor License Agreement — see CONTRIBUTING.md.
