Table of Contents
This one-character bug caused 300 errors over two weeks and survived three separate pull requests without anyone catching it.
// The bug
foreach ($items as $item) {
$notifications = new Notification($item['title'], $item['channel']);
}
// The fix
foreach ($items as $item) {
$notifications[] = new Notification($item['title'], $item['channel']);
}
See it? $notifications = vs $notifications[] =. One character: [].
What Happened
A notification import loop was building a collection of Notification objects to pass to a service. Each iteration was supposed to append to an array. Instead, it was overwriting the variable every time.
Result: only the last Notification object survived. The service downstream expected a collection, got a single object, and threw a TypeError.
Why It Survived Three PRs
Here’s the interesting part. This bug was introduced in the initial implementation and persisted through two subsequent refactors:
- PR #1 — Initial feature implementation. The bug shipped with it.
- PR #2 — Added dynamic ID logic. Touched nearby code but didn’t notice the assignment.
- PR #3 — Added a nested
foreacharound the existing loop. Reviewers focused on the new outer logic and missed the inner loop body.
Each PR added complexity around the bug without ever looking at the bug. The nested loop actually made it harder to spot because there was more code to review.
How It Was Caught
A type-hinted closure caught it:
$collection->map(function (Notification $notification) {
// TypeError: expected Notification, got array
});
PHP’s strict type hints acted as a runtime validator. Without the type hint, the code would have silently produced wrong data instead of throwing an error.
The Debugging Workflow
Once the error surfaced, git blame told the full story:
# Find who last touched the line
git blame path/to/Handler.php -L 366,366
# Check the original PR
git show abc123
# Trace backwards through each change
git log --follow -p -- path/to/Handler.php
This revealed the bug was there from day one. Not a regression — an original sin.
The Reproduce-Before-Fix Rule
Before applying the fix, run the failing code to confirm you can reproduce the error. Then apply the fix and run it again. Two runs:
- Without fix: Error reproduced. Good, you’re testing the right thing.
- With fix: No error. Fix confirmed.
If you can’t reproduce the bug, you can’t be sure your fix actually addresses it.
Lessons
- Type hints are free runtime validators. They catch data structure bugs that unit tests might miss.
- Code review has blind spots when nested loops add visual complexity. Reviewers naturally focus on new code.
- git blame is archaeology. Don’t just find who to blame — trace the full history to understand why the bug persisted.
- Always reproduce before fixing. Two runs: one to confirm the bug, one to confirm the fix.
Leave a Reply