PHP at Scale #18
Spring Code Cleanup - How to Actually Find and Remove Dead Code
Spring is a good time to look at code that’s been sitting in your codebase for months - or years - without doing anything useful. I’m not talking about “I don’t like how this class is structured.” I’m talking about code that is never called under any circumstances. Endpoints nobody queries. Private methods that no execution path ever reaches.
Dead code is a real cost: it slows down onboarding (”what does this do? can I touch it?”), generates false alarms during refactoring and forces you to maintain tests that test nothing. And the longer you keep it, the harder it gets to remove - because more layers of abstraction grow around it.
In this issue, we’ll go through three layers: finding candidates through static analysis, confirming on production that code is truly dead, and automatically cleaning it up with tools like Rector and PhpStorm.
Before You Start - What Actually Counts as “Dead”?
Before you run any tool, it’s worth having a clear mental model - because “dead code” isn’t one problem.
Structurally dead code is a method, class, or property that no execution path reaches. Nobody calls it, no test runs it, no DI container uses it. Static analysis handles this well.
Business-dead code is a different problem - a feature was disabled a year ago, but the classes still sit in src/. The code is technically referenced, maybe even tested, but no real user ever hits that path. Static analysis is blind here. You need production data.
The third category is zombie routes - controllers registered in the router whose endpoints haven’t been queried in months. The framework doesn’t know they’re dead because it never checks whether anyone actually sends requests. This might be because the feature was removed/disabled in the UI, or it is so rarely used by your end users that it might make no sense to keep supporting it.
The distinction matters because it determines the tool. For the first category, you use PHPStan and PhpStorm. For the second and third - tombstones, monitoring and logs. Let’s start with static analysis, because it’s fast and painless.
Static Analysis: Find the Candidates
The classic PHP dead code tool was sebastian/phpdcd. It’s been archived for years - don’t bother. The only tool I’d take seriously today is shipmonk/dead-code-detector. This PHPStan extension understands Symfony DI, Laravel (with limited functionality), Doctrine, Twig, PHPUnit, and several other frameworks out of the box.
Installation is straightforward:
composer require --dev shipmonk/dead-code-detectorMinimal config for a Symfony project:
# phpstan.neon
includes:
- vendor/shipmonk/dead-code-detector/extension.neonThat’s it. The SymfonyUsageProvider automatically assumes all classes in the DI container have their constructor used, so you won’t get hundreds of false positives from autowired services.
If PHPStan reports a method as dead and you don’t understand why, there’s a debug mode that shows the full call graph:
parameters:
shipmonkDeadCode:
debug:
usagesOf:
- App\Reporting\LegacyReportService::generateRun with -vvv and you’ll see exactly what marked that method as alive, or why it wasn’t. This saves a lot of time when you hit a false positive from reflection or a dynamic call.
A few things the tool won’t catch:
dynamic calls like
$object->$method()[I really, really hope you don’t have such code in your projects ;)],calls via reflection,
and methods whose names come from strings in YAML config.
That’s not a bug - it’s the natural limitation of static analysis on a dynamic language like PHP.
PhpStorm as a complement. PhpStorm has its own “Unused Declaration” inspection under Settings > Editor > Inspections > PHP > Unused. It works well in interactive mode - highlighting classes and methods as you work. But its real power is batch mode: Code > Inspect Code... scoped to the whole project. PhpStorm then builds a full reachability graph and shows all unused declarations at once.
When you’ve found a candidate for deletion, don’t just delete it. Use Refactor > Safe Delete (Alt+Delete). PhpStorm checks all usages in the project before deleting - including config files and Twig templates - and if it finds something, it asks what you want to do. This matters in Symfony, where a method might be referenced as a string in services.yaml.
I think it might be a good idea to hook the dead code detection into your CI/CD pipeline, as it is easy to miss code becoming dead in day-to-day work, especially since AI is hesitant to remove code when implementing changes.
Production Doesn’t Lie - Tombstones and Route Monitoring
Static analysis tells you “nothing calls this method in the code.” Production tells you “nobody hits this path in reality.” Those are two different pieces of information, and you need both.
The tombstone technique. Instead of deleting suspicious code immediately, you place a marker in it that logs a call. You wait a few weeks. Silence means safe to delete. A call means the code is alive.
The dedicated PHP library for this is scheb/tombstone. It lets you place tombstones like:('2026-02-26', 'removing in spring cleanup'), and aggregates logs into an HTML report. You can also do it without any library - Monolog is enough:
public function generateLegacyReport(): Response
{
$this->logger->warning('tombstone: LegacyReportController::generateLegacyReport called', [
'placed_at' => '2025-03-01',
'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1] ?? null,
]);
// ...
}Set up an alert in Sentry or Datadog/Grafana on this log, and you have dead code monitoring for free. A practical write-up on using tombstones in Symfony covers some real-world pitfalls worth reading, including the easy-to-miss issue of verifying your logs actually get written on production.
Our approach with Sentry and Apache logs
On one project, we approached this slightly differently. First, we pulled 90 days of Apache access logs (without asset calls). Then we wrote a simple CLI command in Symfony that compared it against the available routes. An outcome was a list of routes not called in the last 90 days.
This gave us an initial list of potentially dead routes. But we still were not 100% sure if we could remove them, and that’s where Tombstones came in handy, but we had our own implementation. What we did - we introduced an attribute that logged a controller call to Sentry:
#[Route('/api/v1/some-legacy-route', name: 'legacy_route')]
#[RouteDeprecated()]
public function legacyRoute(): Response
{
// ...
}So if this route was called, we got a Sentry exception, but you can easily attach it to logs, Slack, or whatever works for you. Six weeks of silence - safe to delete. Blunt, but effective. And it requires no extra library if you already have Sentry.
One important lesson from this: give tombstones at least 30–90 days, especially if you have seasonal traffic - quarterly reports, year-end exports, processes triggered rarely by cron jobs. An endpoint with no hits for two months might be called by an external system once a quarter.
We actually had a route that was called after 6 months and failed with a 404. The good part is that our Product Owner decided not to “reimplement” this feature, as it was barely used.
Automated Removal - Rector and PhpStorm
Once you have a confirmed list of dead code, it’s time to remove it. Some of it you can delete manually via Safe Delete in PhpStorm. But there’s a category of dead code that’s invisible to the naked eye - redundant operations, dead branches, useless assignments. This is where Rector comes in.
What is Rector? It’s an automated PHP refactoring tool based on abstract syntax trees (AST). It analyzes your code structurally - it doesn’t search for text, it understands types and context. You can tell it to “remove all useless variable assignments”, and it will do it accurately across the entire project in seconds. Think of it as a code transformation engine: you configure what changes you want, run --dry-run to preview them, then apply. This intro from Tighten explains the basics well if you haven’t used it before. Rector is not a linter - it doesn’t warn, it changes code.
Installation:
composer require --dev rector/rector
vendor/bin/rector # generates rector.php if it doesn't existUse levels, not sets. The most common mistake is enabling withPreparedSets(deadCode: true) immediately. Rector then proposes dozens of changes at once, the PR is enormous, the review takes weeks, and somewhere along the way, something goes wrong.
The official Rector documentation recommends a different approach - levels:
// rector.php
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withDeadCodeLevel(0); // start at 0, increment by 1 per PRwithDeadCodeLevel(0) is literally one rule. The easiest to review. Merged. Next PR, withDeadCodeLevel(1). And so on up to the maximum. Each PR is small, understandable, and safe to review.
Always start with a dry-run to see what Rector wants to change:
vendor/bin/rector --dry-runWhat does Rector remove automatically in the dead code set? Among other things: useless assignments ($a = 5; return $a; → return 5;), redundant returns at the end of methods, dead branches (if (true)), useless casts like (string) $stringVariable, empty try-catch blocks, and unused private methods. There are 59 rules in the dead code set - worth a browse to see the full scope of what Rector can do for you.
One warning. Rector works at the AST level - it sees code, but not runtime behavior. It might propose removing a __toString method that looks unused, but is actually called by Twig through automatic string conversion. Dry-run plus code review are required steps, not optional ones.
What about AI? PhpStorm now has built-in AI integration, and there are tools like Claude Code, OpenCode and Codex. I find them useful for one specific thing: understanding the context of suspicious code. “What was this service supposed to do? Why was this method here?” - AI reconstructs intent well from names, comments, and git history. But don’t delegate the deletion decision to it. AI doesn’t know what’s being called in production.
The Safe Process - Doing This on a Live Project
Putting all three layers together, a process that makes sense to me, looks like this:
Start with static analysis (shipmonk/dead-code-detector + PhpStorm inspections) to get a list of candidates. Then verify on production through tombstones or log analysis - this eliminates false positives and zombie routes. Only then remove: manually via Safe Delete for classes and methods, and through a conscious team decision for business-dead code. Next, remove automatically via Rector for structural dead code.
A few rules I stick to in this process:
Separate PRs per type of removal. Structural dead code (Rector, Safe Delete) goes in one PR or even one per level as described above. Removing a disabled feature goes in a separate one, with a description of what it was and why we’re sure we can remove it. Mixing these two types makes code review impossible.
Don’t do a “cleanup sprint.” I’ve seen teams block feature delivery for two weeks on a cleanup sprint that ended in a merge conflict nightmare. Better to wire Rector into CI and increment the dead code level by one per sprint. Or simply apply the Boy Scout rule - leave the code a little cleaner than you found it, as part of every PR.
PS. We help teams modernize PHP applications and fix the architectural problems that hold them back - the kind of stuff I write about here. If that sounds like your situation, we currently have a slot for a new project. Just email me or reach out on LinkedIn.
Why is this newsletter for me?
If you are passionate about well-crafted software products and despise poor software design, this newsletter is for you! With a focus on mature PHP usage, best practices, and effective tools, you'll gain valuable insights and techniques to enhance your PHP projects and keep your skills up to date.
I hope this edition of PHP at Scale is informative and inspiring. I aim to provide the tools and knowledge you need to excel in your PHP development journey. As always, I welcome your feedback and suggestions for future topics. Stay tuned for more insights, tips, and best practices in our upcoming issues.
May thy software be mature!


