Extracted from the README, which had grown to carry this subsystem's full manual — suppression rules, tags, scan-root guarding and the framework caveats — inside a file people open to learn how to run a clone detector. It is a subsystem, so it gets a document.
phpcpd --orphans src/ is the whole entry point; everything below is what the
flag means and when to trust it.
Clones 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
Scanned symbols (829), files (764); orphaned (2), possible (2), suppressed (30), planned (1).
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 — or in a config/*.php array, which is Laravel's native config format. See below. |
template |
Named in a .blade.php, .twig, .latte, or .tpl file. In a Laravel application a view is a primary call site, so a class called only from a template is referenced, not dead. |
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, a *Test class, or a class declared in a Database\Seeders / Database\Factories namespace, which the framework instantiates by convention. |
discovery |
Selected by a sibling file that globs its own directory and instantiates by filename — see below. |
convention |
A companion class a trait names by appending a literal suffix to its consumer's own class name — see below. |
planned |
Carries @phpcpd-planned. |
Reference detection asks whether a name is mentioned. That question has no answer for a class whose name is never written down — assembled at runtime from a filename, or from a base class plus a suffix. The scan correctly finds no mention and incorrectly concludes the class is dead. On a live Laravel monolith audit this family accounted for 64 of 94 reported dead symbols: 68% false positives, every one of which would have deleted wired code.
The fix is not to widen reference detection, which costs precision everywhere. It is to recognise the wiring and cite it, so a suppression is something you can check and disagree with.
discovery — a directory scan that instantiates by filename. A base class globs a directory,
derives a fully-qualified name from each filename, checks class_exists and instantiates the
result; a front controller dispatches through the map. Nothing names the discovered classes, so all
of them read as dead — 52 in one directory on the audited project.
All three token signals are required, because each alone is ordinary code:
- a directory read —
glob/scandir/ aDirectoryIterator— whose argument is anchored at__DIR__; - an instantiation through a variable (
new $classor$class::); - a
class_exists(guard.
The loop's glob pattern is then applied to filenames, so only the classes the loop actually reaches are spared. A class sitting beside them that the pattern does not select stays reported — that is the difference between a rule that spares live endpoints and one that only makes the number go down.
Discovered by a directory scan (instantiated from its filename) — 3
- Class Neutral\Handlers\AlphaHandler
src/Handlers/AlphaHandler.php:4
→ discovered by a directory scan — instantiated from its filename behind class_exists
⤷ discovered by the loop at src/Handlers/HandlerRegistry.php:10
Boundary — the __DIR__ anchor is load-bearing. A glob over a path that is not built from
__DIR__ (a configured directory, a path argument, an absolute string) is deliberately out of
scope: without the anchor nothing in the token stream says which directory on disk the loop reads,
so the rule would be suppressing a directory it cannot name. Such a loop is left unrecognised and
the classes it discovers stay reported.
convention — a companion class named by suffix (12 of the 64). A trait resolves a companion
class by concatenating static::class (or class_basename(static::class)) with a literal suffix —
the translatable idiom, where a model Article is paired with an ArticleTranslation. The
companion's name is never written, so every companion read as dead.
The suffix is registered with the type that declared it, and both halves of the claim are then
required before <X><Suffix> is spared:
- a class
<X>exists in the scanned set, and - that
<X>actuallyuses the trait that declared the suffix.
Drop either and the rule becomes "any class ending in Translation", which would spare the dead
ones too. A companion whose base does not exist, or whose base does not use the trait, stays
reported.
Companion class named by convention (suffix declared by a trait) — 1
- Class Neutral\Content\ArticleTranslation
src/Content/ArticleTranslation.php:3
→ companion class — Neutral\Content\Article uses Neutral\Support\Translatable, which resolves this name by suffix at runtime
⤷ suffix declared at src/Support/Translatable.php:8
Boundary — the suffix must come from a literal. Both shapes the field data showed are read:
static::class . 'Translation' and . config('x.suffix', 'Translation'), where the literal is the
config() default. A suffix with no literal anywhere — config('x.suffix') resolved entirely at
runtime — is out of scope, because inventing one would mean suppressing by class-name pattern,
which is the one thing no rule here does.
Both of these are suppression rules, not excludes, on purpose. Excluding the directory would hide the genuinely dead code sitting next to the wired code — and the audit found exactly that.
Laravel's native config format is PHP: config/*.php returns an array of Provider::class entries
and quoted fully-qualified names. With .php absent from the config sweep's suffix list, none of
that wiring was visible — measured on the audited monolith, the config rule suppressed exactly
one symbol repo-wide, while the classes it should have accounted for sat in those arrays. And
because a scan is normally pointed at src/, config/ is outside the scanned source, so the
::class entries are not code references the collector ever sees either.
Both citation styles resolve, since the sweep matches on name shape rather than on syntax:
Registered in a config file — 2
- Class Neutral\Services\QueueService ⤷ named in config/services.php:8 ('Neutral\Services\QueueService')
- Class Neutral\Services\ReportService ⤷ named in config/services.php:7 (\Neutral\Services\ReportService::class)
This moves those symbols from possible (a name that showed up in a string somewhere) to suppressed with a config citation — which is the tier split people actually act on.
Boundary — only directories named config/. Arbitrary .php files are not swept as
configuration, and must not be: every .php file in the project is already read as source, where a
class name is scored as a reference or as the weak string-mention signal. Reading them a second time
as configuration would promote every string literal anywhere to a config registration — a
suppression rule that fires on everything. A config path segment is a structural statement about
the file's role, which is the standard every other rule here meets.
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, unfiltered*.blade.php sits in the Laravel preset's excludes and belongs there — templates are repetitive by
nature and would flood a clone report. Carrying that exclude into reference detection does the
opposite of its purpose: it hides the call sites, so a class used only from a view is reported dead.
The two file sets are therefore separate. Clone detection keeps its excludes; reference scanning
additionally reads templates for symbol mentions, without ever treating them as scannable source for
clones. An exclude is dropped from the reference scan only when it blinds templates specifically —
it matches a template filename but not a plain .php one — so vendor and demo still prune, and
only patterns like *.blade.php step aside.
The config and template rules are the last questions the decision tree asks: a
symbol reaches them only after a code reference, an author tag, composer.json, the
namespace rule and the fixture rule have all declined. So the two trees are swept lazily —
on the first symbol that gets that far, once per run, and never at all on a scan where
nothing does. A project with no unreferenced symbols never opens a .yaml or a
.blade.php file; a project with a hundred of them still reads each tree once.
That laziness is also what pays for the config/*.php pass: finding those files means a second walk
over the source tree, which is affordable precisely because it happens at most once per run and only
when a symbol actually reaches the config rule.
This changes only when the files are read. The roots and excludes are fixed when the scan resolves its scope, so a deferred sweep sees exactly the tree the run was pointed at, and the report is identical either way.
A symbol is called dead when nothing references it, and "nothing" is a claim about the entire project. A scan narrower than the autoload roots cannot support that claim, so it says so:
Warning: --orphans is scanning below the autoload roots of /home/you/app/composer.json.
A symbol is called dead when *nothing* references it, which is a claim about the
whole project. Code outside this scan can still reference what is reported here —
scan the project root for a result worth acting on.
Measured on a modular monolith: scanning one controller directory reported nine live controllers as
"whole file is unwired". Every one of them was imported from a route file in a different package,
outside the scanned path. The warning fires only when every scan root sits below every directory
composer.json maps — phpcpd --orphans src/ in an ordinary package stays quiet, because src/ is
what the manifest maps.
Two 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']);