Lesson 16 of 17 · Living With Code You Didn't Write
Reading a Codebase You Didn't Write
The first week
You join a team. The product is a Laravel application that has been in production for years, and most of the people who wrote it have left. Your first ticket is small: a guest's confirmation email shows the wrong check-out time when the stay crosses a daylight saving change. Someone says "it's probably in the mailer". Nobody is sure.
Lesson 4 was about writing code for the person who reads it later. This lesson is about being that person, in a codebase where nobody did you that favour. In my experience most of the codebases you'll be paid to work on already exist when you arrive, and the reading skill decides whether your first month produces safe changes or a proposal to rewrite.
Start with the product, not the files
The temptation is to open the editor and start at app/. Don't. The code is the answer to a question you don't know yet, which is what the product does and for whom.
Run the application. Click through the flow your ticket touches, as a user. Make a reservation, confirm it, check in, check out. Read the confirmation email that goes out. This tells you which words the product uses and which screens exist. Lesson 11 explained why those words matter: the business runs processes, and the code either follows them or hides them.
Then read the things that describe the whole system in a page or two:
composer.json, for what the application depends on and which PHP version it runs. A dependency list is a list of decisions somebody made.The routes files, for every way a request can enter. In a Laravel app that is
routes/web.php,routes/api.phpandroutes/console.php, which also holds the schedule since Laravel 11 (before that,app/Console/Kernel.php).The
database/migrationsfolder, read in order, for the schema and the story of how it grew. Lesson 2's accounts table, with columns added one feature at a time, is visible in the migrations before it is visible anywhere else.The README, if one exists and is dated. Treat anything older than the last major framework upgrade as history rather than instruction.
You still haven't read a controller. You have the vocabulary, the entry points and the shape of the data.
Find the joints
A codebase is easier to hold in your head as a small number of joints than as a large number of files. Five questions find them:
Where do requests land? Controllers, console commands, queue jobs, event listeners. That is the edge of the system.
Where do the business rules live? Follow one request from the controller inward until you hit code that says something like "a reservation can't be confirmed without a payment". If you can't find a place where rules live, that is a finding, and an important one. It means the rules are spread across controllers, models and templates, which is the shape Lesson 15 showed when a rule escaped the model into a template.
Where does the code talk to the database, and where does it call other systems? Eloquent models, query builders, HTTP clients, SDKs. Note which business code reaches for them directly. Lesson 10 explained why that matters for tests and upgrades.
Which way do the dependencies point? Does the code that holds the rules import the framework, or does the framework call into it?
Is the structure horizontal or vertical?
Controllers/,Services/,Repositories/means the team kept the framework's defaults.Reservations/,Housekeeping/,Billing/means somebody decided the feature is the unit of change (Lessons 5 and 13).
Write the answers down as you go, one line each. This is the map you'll keep using long after the first week.
Let the history tell you where it hurts
The files that matter most are the ones that keep changing. Adam Tornhill's Your Code as a Crime Scene calls them hotspots: files with high change frequency and high complexity at the same time. A large, complicated file with no commits in the last year is not your problem. A medium-sized file that changes every week is.
Git already has the data. This lists the twenty most-changed files over the last twelve months:
git log --since="12 months ago" --format=format: --name-only \
| grep -v '^$' \
| sort \
| uniq -c \
| sort -rn \
| head -20That counts changes, which is half of Tornhill's definition. For the other half, run wc -l on the files at the top. Tornhill uses lines of code as a rough measure of complexity, because it is fast and works in any language. A long file near the top of the change list is a hotspot.
The top of that list is Lesson 5's busy intersection: the file that changes every sprint, for billing reasons, then auth reasons, then export reasons. It is where your ticket will most likely end up, where the bugs cluster, and where a careless change does the most damage. Read those files first and read them slowly.
For a single file, git log --follow -p -- path/to/File.php walks through every change to it, newest first, including the changes made before a rename. Read it from the bottom up: adding --reverse to --follow silently drops commits. Reading a file's history is often faster than reading the file, because the commits tell you which lines were added together and, when the author bothered, why. Lesson 17 is about being that author.
Read the tests before the implementation
If the codebase has tests, they are the closest thing you have to a specification. A test named reservation_cannot_be_confirmed_without_payment tells you in one line a rule you'd otherwise reconstruct from the controller.
Read the test folder for the area your ticket touches before the code it tests. Note what is covered and what isn't. Note how much setup each test needs, because the amount of setup tells you how coupled the code under test is. Lesson 10's first proof needed every migration and two factories to check whether two date ranges overlap. A test that boots the framework, seeds four tables and fakes a payment gateway to check a date calculation is telling you the date calculation is buried somewhere it shouldn't be.
If there are no tests, write that down as the first line of your map. It changes what a safe change looks like.
Keep a map, not a memory
What you learn in the first weeks drains out of your head the way Lesson 4 said your own context does: in days. Keep one notes file, in the repository or next to it, with:
The answers to the five questions.
A glossary. Every word the product uses, and the name the code uses for the same thing when they differ. The gap between
Bookingin the code and "reservation" in the business is the drift Lesson 12 removed by renaming, and knowing the mapping saves you from a wrong search every day.The hotspot list and what each hotspot is for.
Questions you can't answer from the code. Who to ask, and what they said.
The notes file is your own map, and its main use is that you re-read it before touching anything.
The first change
The mailer calls this class to work out the check-out time:
<?php
declare(strict_types=1);
namespace App\Reservations;
use DateTimeImmutable;
use DateTimeZone;
final class CheckOutTime
{
private const CHECK_OUT_AFTER_MIDNIGHT = 11 * 60 * 60;
public function forStayEnding(DateTimeImmutable $checkOutDay): DateTimeImmutable
{
$midnight = new DateTimeImmutable(
$checkOutDay->format('Y-m-d'),
new DateTimeZone(config('hotel.timezone')),
);
return $midnight->setTimestamp($midnight->getTimestamp() + self::CHECK_OUT_AFTER_MIDNIGHT);
}
}It adds eleven hours' worth of seconds to midnight. On the night the clocks go forward, midnight to 11:00 is only ten hours long, so the email says 12:00.
Before you change it, pin down what it does today. Michael Feathers, in Working Effectively with Legacy Code, calls this a characterization test: a test that records the code's current behaviour, whether or not that behaviour is correct. You don't assert what should happen. You run the code, write down what it does, and assert that.
This class can't go in a plain PHPUnit test yet, because config() only works inside a booted Laravel application. Outside one it throws Target class [config] does not exist. Feathers has a name for the way out. A seam is a place where you can change the code's behaviour without editing the code at that place: an injected dependency, an interface, a method a test subclass can override. Here the seam is a constructor parameter that falls back to the config value, which Feathers calls Parameterize Constructor. Every existing caller keeps working, and the test can pass its own timezone:
<?php
declare(strict_types=1);
namespace App\Reservations;
use DateTimeImmutable;
use DateTimeZone;
final class CheckOutTime
{
private const CHECK_OUT_AFTER_MIDNIGHT = 11 * 60 * 60;
private readonly DateTimeZone $hotelTimezone;
public function __construct(?DateTimeZone $hotelTimezone = null)
{
$this->hotelTimezone = $hotelTimezone ?? new DateTimeZone(config('hotel.timezone'));
}
public function forStayEnding(DateTimeImmutable $checkOutDay): DateTimeImmutable
{
$midnight = new DateTimeImmutable($checkOutDay->format('Y-m-d'), $this->hotelTimezone);
return $midnight->setTimestamp($midnight->getTimestamp() + self::CHECK_OUT_AFTER_MIDNIGHT);
}
}That edit happens before any test exists, which breaks the rule this section opened with. Feathers calls this the legacy code dilemma: to change code safely you need tests, and to put tests in place you often have to change code. His answer is to keep that first change as small and mechanical as possible. Here no existing caller passes a timezone, so every one of them still reads the same config value.
Finding or introducing one seam is often the entire first step of a change in inherited code. Lesson 10's ports are what seams look like when they were designed in rather than found afterwards.
With the seam in place, the characterization test runs without the framework:
<?php
declare(strict_types=1);
namespace Tests\Characterization;
use App\Reservations\CheckOutTime;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CheckOutTimeTest extends TestCase
{
#[Test]
public function records_what_the_current_code_produces(): void
{
$checkOut = new CheckOutTime(new DateTimeZone('Europe/Berlin'));
// Every expected value below came from running the existing code,
// not from the business rule. The last one is the bug from the ticket:
// clocks go forward on 2026-03-29 and check-out drifts by an hour.
// If any value changes, the behaviour changed, which is what this
// test exists to catch.
self::assertSame(
'2026-03-27 11:00',
$checkOut->forStayEnding(new DateTimeImmutable('2026-03-27'))->format('Y-m-d H:i'),
);
self::assertSame(
'2026-03-28 11:00',
$checkOut->forStayEnding(new DateTimeImmutable('2026-03-28'))->format('Y-m-d H:i'),
);
self::assertSame(
'2026-03-29 12:00',
$checkOut->forStayEnding(new DateTimeImmutable('2026-03-29'))->format('Y-m-d H:i'),
);
}
}The test locks in the existing behaviour, including the bug, so that when you fix it you can see exactly one assertion change and nothing else.
Then make the change, small, inside the joint you found. Then, and only then, the Boy Scout Rule from Lesson 9: one or two readability improvements in the file you're already in, and nothing beyond it.
What to leave alone
The codebase will be full of things you'd have done differently. Most of them are not your problem yet.
Lesson 9 had a category for this: code you don't understand, written by someone who isn't here anymore, which probably has its reasons. Until you understand it, calling it debt is a guess. The ReportsController with eight hundred lines and no commits since the last framework upgrade can stay exactly as it is until a report changes.
Resist the rewrite proposal. Lesson 1 described the project where someone proposes it and someone else says the rewrite will end up in the same place. In your first weeks you don't yet know which parts of the mess the product depends on, and Lesson 9 made the same point: the rewrite that cleans up code you don't understand tends to reintroduce the bugs the original author was working around.
Migrating a legacy application towards a better architecture is a different job, with its own strategies, and the third course in the series covers it. This lesson is about the weeks before any of that is a sensible thing to propose.
Ask the people
The code is usually not the best source for "why". A conversation with the person who has been there longest answers questions that reading won't: why the pricing has that special case, which module everyone is afraid of, which customer the strange column was added for.
Write the answers into your map. When you make the change, write the why into the commit and the code, so that the next person doesn't need the conversation. That is the subject of Lesson 17.
The one thing to remember
An inherited codebase is read from the outside in: the product, the entry points, the schema, the joints, the hotspots, and only then the file your ticket touches. Pin down what the code does before you change what it does, and leave alone the parts that nobody is trying to change.
Exercise
Pick a codebase you work in but didn't start, or any open-source PHP project too big to hold in your head.
Run the hotspot command from this lesson and list the top ten files.
Answer the five joint-finding questions in one line each.
Pick the top hotspot and read its git history with
git log --follow, from the bottom up. Write down the one commit where it started to become a hotspot.
If the third step is impossible because the commit messages say "fix" and "wip", you've just found the motivation for the next lesson.
Related Links
- Working Effectively with Legacy CodeMichael Feathers's book. The source of characterization tests and seams.
- The key points of Working Effectively with Legacy CodeNicolas Carlo's summary of the book, if you want the ideas before the whole book.
- Your Code as a Crime SceneAdam Tornhill on hotspots and reading a codebase through its version history.
- Characterization testA short definition of the technique and where the term came from.
- Legacy code, seams, and the most important design guidelineMike Bland on seams and why they are the design guideline that matters most.