Churn PHP: How to spot the monsters in your PHP project
By Giuseppe Maccario on Wed Jan 22 in PHP, Refactoring
TL;DR
Churn PHP is a tool that helps identify the most problematic code in a project by combining complexity and change frequency (churn). Code with high complexity and frequent changes is a perfect candidate for refactoring, a practice that should be part of daily development to improve code maintainability and stability.
Table of Contents
- Introduction
- Code Complexity
- Refactoring
- What is Churn PHP
- Metrics
- Practical Refactoring Examples
- Deep Dive into Churn PHP
- Conclusion
Introduction
How many times, starting at a new company, have you opened a file containing just one class and found—surprise!—it’s 3,000 lines long? It’s happened to me so many times. And I always ask myself: Why don’t we developers spend time on a fundamental task that could save time and money in the long run? I’m talking about refactoring.
People have told me, “If it works, don’t touch it,” or “It’s just details,” or the classic excuse, “We don’t have time.” But somehow, between memes and smoke breaks, time for other things always pops up. Sure, touching code means extra work: More tests (manual or automatic), more commit messages, more headaches. But we’re developers, not working in fast food—spoiler: I worked in fast food when I was younger.
For me, reducing code complexity is and must be a priority. Refactoring is just as much a development task as writing tests, fixing bugs, or adding new features.
Code Complexity
The problem with a 3,000-line class isn’t its length but the complexity of reading a “monster” like that. Lots of branches mean keeping track of too many variables and flows. It’s just too mentally draining, leading to errors. Plus, testing a 3,000-line beast is nearly impossible because of dependencies and exceptions.
How do we measure code complexity? One starting point is Cyclomatic Complexity, a metric developed by Thomas J. McCabe, Sr. in 1976. It measures the number of linearly independent paths through a program’s source code. In simpler terms, it tells you how many decisions (if, loops, switches, etc.) are in your code, helping assess its maintainability and the minimum tests needed. High complexity means the code is harder to understand and test. And that’s why high complexity is (almost) always bad. I say almost because if a class is rarely touched, it might not be a big problem.
Refactoring
Like I mentioned earlier, I’ve worked in many companies—Italian and international, big and small—where code complexity wasn’t even seen as an issue. And no, it’s not just legacy projects that suffer from this; even ongoing projects sometimes carry this weight because developers—and managers—don’t prioritize reducing complexity. Every day, complexity piles up, and one day, it’ll be labeled “legacy,” and future developers will say it’s too complicated.
Dedicating an entire sprint to massive refactoring isn’t the solution. Instead, we should integrate refactoring into our daily practices. By refactoring as we make changes, we end up with progressively better code. But how? Which files should we prioritize? A good rule is to prioritize files with high complexity and frequent changes.
This combination of complexity and frequent modifications will guide your refactoring efforts.
What is Churn PHP
Churn PHP helps identify files ripe for refactoring. Michael Feathers coined the term “churn” to describe how often files in a project change. It’s a simple value, much like the one used to measure code complexity. Assuming each class is in its own file and every class change equals a new commit in version control, you can measure churn by counting the number of commits affecting a file.
Churn PHP combines complexity and churn metrics to give numerical values that highlight classes that are hard to modify but are modified often.

Metrics
Times Changed: This refers to the number of times a specific file or class has been modified over a given period. It’s simply a count of the commits involving that file. In a PHP project, a high number of changes to a class or function might indicate areas of code that are frequently updated, potentially signaling instability or evolving requirements.
Complexity: Complexity refers to how intricate the code in a particular class or file is. This includes factors like the number of functions/methods, conditional statements, nested loops, and the overall structure of the code. A more complex file or class is harder to maintain, understand, and modify, which might result in higher churn. Complexity is often measured using tools like Cyclomatic Complexity, which evaluates the number of independent paths through the code.
Score: The score is a combined metric of Times Changed and Complexity. It quantifies how much attention a particular file or class might require. A higher score indicates that the file has been changed often and is also highly complex, suggesting it’s a critical or unstable part of the code. This might point to areas needing refactoring, additional tests, or better documentation for long-term maintainability.
A high churn value combined with high complexity might indicate hard-to-maintain code or bug-prone areas, helping guide decisions for refactoring or simplifying the code.
Practical Refactoring Examples
Too much theory can obscure the practical impact these metrics have on our work. Let me show a concrete example of simplifying a class to make the discussion more actionable. It’s a simple case, but there are more complex ones, like interdependent classes requiring new design patterns to break dependencies.
Here’s the ItemController.php class from a Laravel project:
<?php
/* NOTE: here namespace and use */
class ItemController extends Controller
{
/**
* @param Request $request
* @return JsonResponse
*/
public function create(Request $request): JsonResponse
{
$validate_data = $request->validate([
'slug' => 'required|string',
'item' => 'required|string',
'session_id' => 'nullable|string',
]);
// Find message/letter for its slug
$message = Message::where('slug', "like", "%" . $validate_data['slug'] . "%")->first();
if (!$message) {
return response()->json([
'item' => '',
'session_id' => '',
'slug' => '',
'error' => 'No message found!'
]);
}
// Is there already an items for this user?
$item = null;
if (isset($validate_data['session_id'])) {
$item = Item::where('session_id', $validate_data['session_id'])->where('message_id', $message->id)->first();
}
if ($item) {
Item::destroy($item->id);
}
if ($validate_data['item'] === ''
|| $validate_data['item'] === Constants::LINK) {
return response()->json([
'item' => '',
'session_id' => '',
'slug' => '',
'error' => 'No item selected!'
]);
}
// Save new items
$item = new Item();
$item->message_id = $message->id;
$item->user_id = (Auth::check()) ? Auth::user()->id : Constants::NOT_LOGGED_USER_ID;
$item->name = $validate_data['name'];
$item->session_id = (isset($validate_data['session_id'])) ? $validate_data['session_id'] : \Session::getId();
$item->save();
return response()->json([
'item' => $item->item,
'session_id' => $item->session_id,
'slug' => $validate_data['slug'],
]);
}
/**
* @param Request $request
* @return JsonResponse
*/
public function getPublicItems(Request $request)
{
$request->merge(['slug' => $request->slug]);
$validate_data = $request->validate([
'slugs' => 'required|string',
]);
// get messages from slugs
$arrSlugs = json_decode($validate_data['slugs'], true);
$query = Message::query();
foreach ($arrSlugs as $slug) {
$query->orWhere('slug', "like", "%-" . $slug);
}
$messages = $query->get(['id', 'slug']);
$ids = array();
foreach ($messages as $message) {
$ids[] = $message->id;
}
// get items by messages ids
$items = Item::select('item', 'message_id', \DB::raw('count(item) as item_count'))
->whereIn('message_id', $ids)
->groupBy('item', 'message_id')
->orderBy('message_id', 'desc')
->orderBy('item', 'desc')
->get();
// Prepare result
$results = [];
foreach ($items as $item) {
foreach ($messages as $message) {
if ($item->message_id == $message->id) {
$result = array();
$result['slug'] = $this->getPublicSlug($message->slug);
$result['item'] = $item->item;
$result['count'] = $item->item_count;
$results[] = $result;
}
}
}
return response()->json([
'items' => $results,
]);
}
}
This controller in the Http/Controllers directory was flagged by Churn PHP with these values:
+---------------------------------------------------+---------------+------------+-------+
| File | Times Changed | Complexity | Score |
+---------------------------------------------------+---------------+------------+-------+
| app/Http/Controllers/ItemController.php | 14 | 17 | 0.102 |
+---------------------------------------------------+---------------+------------+-------+
It’s clearly a class I’ve modified often, with multiple public methods, meaning it has a lot of responsibilities, violating the Single Responsibility Principle (SRP). Additionally, it contains two private methods only used in one of the public methods, making the structure unclear.
This class has been changed often and contains several public methods, meaning it has many responsibilities and violates the Single Responsibility Principle (SRP). It also contains two private methods, used only in one of the public methods, further complicating the situation.
Here are the steps I followed to simplify this class:
- Created new Request classes to move validation logic out of the controller.
- Encapsulated logic into private methods, reducing redundant comments and making the code easier to read. For example, instead of a comment like
// Get messages by slugs
I used PHPStorm’s Extract Method feature to create a getMessagesBySlugs method. This makes the code self-explanatory. - Split the public methods into separate classes with single responsibilities. Each new class has one public __invoke() method and is placed in the Item folder. Route files now directly reference the required class instead of specifying method names.
- Used the Return Early Pattern to simplify logic and avoid deep nesting.
The following are the refactored classes.
CreateController.php
<?php
/* NOTE: here namespace and use */
class CreateController extends Controller
{
/**
* @param ItemCreateRequest $request
* @return JsonResponse
*/
public function __invoke(ItemCreateRequest $request): JsonResponse
{
$validateData = $request->validated();
if ($validateData['item'] === ''
|| $validateData['item'] === Constants::LINK) {
return response()->json([
'item' => '',
'session_id' => '',
'slug' => '',
'error' => 'No item selected!',
]);
}
$message = Message::where('slug', 'like', '%' . $validateData['slug'] . '%')
->first();
if (!$message) {
return response()->json([
'item' => '',
'session_id' => '',
'slug' => '',
'error' => 'No message found!',
]);
}
if ($sessionId) {
$this->deleteItemForUserIfExists($validateData['session_id'] ?? null, $message->id);
}
$item = $this->storeNewItem($message->id, $validateData);
return response()->json([
'item' => $item->item,
'session_id' => $item->session_id,
'slug' => $validateData['slug'],
]);
}
/**
* @param string $sessionId
* @param int $messageId
*
* @return void
*/
private function deleteItemForUserIfExists(string $sessionId, int $messageId): void
{
$item = Item::where('session_id', $sessionId)
->where('message_id', $messageId)
->first();
if ($item) {
$item->delete();
}
}
/**
* @param int $message
* @param array $validateData
*
* @return Item
*/
private function storeNewItem(int $messageId, array $validateData): Item
{
$item = new Item();
$item->message_id = $messageId;
$item->user_id = (Auth::check())
? Auth::user()->id
: Constants::NOT_LOGGED_USER_ID;
$item->name = $validateData['name'];
$item->session_id = (isset($validateData['session_id']))
? $validateData['session_id']
: \Session::getId();
$item->save();
return $item;
}
}
GetListController.php
<?php
/* NOTE: here namespace and use */
class GetListController extends Controller
{
/**
* @param GetItmesListRequest $request $request
* @return JsonResponse
*/
public function __invoke(GetItmesListRequest $request): JsonResponse
{
$validateData = $request->validated();
$messages = $this->getMessagesBySlugs($validateData['slugs']);
$ids = [];
foreach ($messages as $message) {
$ids[] = $message->id;
}
// get items by messages ids
$items = Item::select('item', 'message_id', \DB::raw('count(item) as item_count'))
->whereIn('message_id', $ids)
->groupBy('item', 'message_id')
->orderBy('message_id', 'desc')
->orderBy('item', 'desc')
->get();
$results = $this->prepareResults($items, $messages);
return response()->json([
'items' => $results,
]);
}
/**
* @param string $slugs
* @return Builder[]|Collection
*/
private function getMessagesBySlugs(string $slugs): array|Collection
{
$arrSlugs = json_decode($slugs, true);
$query = Message::query();
foreach ($arrSlugs as $slug) {
$query->orWhere('slug', 'like', '%-' . $slug);
}
return $query->get(['id', 'slug']);
}
/**
* @param array|Collection $items
* @param array|Collection $messages
*
* @return array
*/
private function prepareResults(array|Collection $items, array|Collection $messages): array
{
$results = [];
foreach ($items as $item) {
foreach ($messages as $message) {
if ($item->message_id == $message->id) {
$results[] = [
'slug' => $this->getPublicSlug($message->slug),
'item' => $item->item,
'count' => $item->item_count,
];
}
}
}
return $results;
}
}
With further optimizations, such as the introduction of a service class and more structured exception handling, the code could be made even more robust and reusable.
Learn more about Churn PHP
Churn PHP is a powerful tool, but it is often used with basic configurations that fail to exploit its full potential. By customizing the churn.yml file, you can tailor the analysis to meet the specific needs of your project. For instance, you can exclude irrelevant files or directories, such as vendor/ or storage/, or set custom thresholds for complexity and churn to highlight only truly critical files. Below is an example of a churn.yml configuration file:
# This list is used only if there is no argument when running churn.
# Default: <empty>
directoriesToScan:
- src
- tests/
# Files to ignore when processing. The full path to the file relative to the root of your project is required.
# Also supports regular expressions.
# Default: All PHP files in the path provided to churn-php are processed.
filesToIgnore:
- vendor/*
- storage/*
# The command returns an 1 exit code if the highest score is greater than the threshold.
# Disabled if null.
# Default: null
maxScoreThreshold: 0.9
Moreover, Churn PHP supports various output formats, such as JSON, which are ideal for advanced integrations with dashboards or CI/CD pipelines. Configuring the tool to perform incremental analyses directly on GitHub Actions or GitLab CI, for example, allows you to identify problematic files in real-time, before the code is merged. A well-crafted configuration turns Churn PHP into an indispensable ally for maintaining high code quality without slowing down development.
For installation, compatibility, and additional configurations, refer to the Churn PHP GitHub repository.
Rector and Churn PHP: Two Complementary Tools for Refactoring
A final note on another powerful tool used for refactoring: Rector.
Rector is an automatic refactoring tool for PHP that allows you to transform source code programmatically and automatically by applying predefined or custom rules. Unlike Churn PHP, which focuses on analyzing code to identify classes with a high rate of changes (churn) and complexity, Rector acts directly on the code, performing refactoring quickly and safely.
The combined use of these tools is highly effective: Churn PHP helps identify files that require priority attention through concrete metrics, while Rector can automate many of the necessary changes, such as removing redundant code, aligning with modern standards, or simplifying complex patterns based on developer-defined rules. This synergy optimizes the refactoring process, balancing intelligent analysis and automation to achieve cleaner, more maintainable code without slowing down development.
For installation and documentation, refer to the Rector GitHub repository.
Conclusions
Refactoring is not just a technical practice but a strategy that can transform how a team approaches software development. Tools like PHPStan and Churn PHP help identify critical areas, while tools like Pint ensure consistent coding standards (e.g., PSR12). However, success relies on long-term strategies: Integrating refactoring into daily activities and fostering a culture that considers it an essential part of the development process.
It’s crucial to balance the desire to improve code with the risks, such as introducing bugs, by adopting agile and incremental approaches. Finally, metrics like churn not only guide refactoring efforts but also reveal the overall health of the code and the team, providing insights to enhance collaboration and general quality.
What about you? What strategies do you use to tackle refactoring? Feel free to share your thoughts and join the conversation about refactoring techniques.
No comments yet. Be the first one!