Originally published on decentphp.dev — part of the Writing decent PHP course. View this lesson on the original site.
A rename is a refactor with its own risk profile
Lesson 2.11 was about why to rename when names drift. This lesson is about how to do it without breaking anything.
Renaming code sounds like the safest refactor there is — you're just changing letters. In practice, a sloppy rename can:
- miss callers in views, config, or raw SQL the IDE doesn't analyse
- break production because a queued job references the old class name
- silently break an API contract for a mobile app you didn't remember existed
- make
git blameharder than it needed to be - get bundled with a behaviour change and sneak a bug past review
The fix isn't to avoid renaming. It's to rename deliberately, with a small set of habits that turn it from a "scary change" into a boring one.
Before
A careless rename PR. Four files changed, one commit, mixed with logic changes.
// PR title: "Rename validateUser, fix race condition, tidy logging"
- public function validateUser(array $input): array
+ public function registerUser(array $input): array
{
if (empty($input['email']) || !filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'reason' => 'invalid_email'];
}
// ...
- $this->mailer->send($user['email'], 'Welcome!');
+ $this->mailer->queue($user['email'], 'Welcome!');
return ['ok' => true, 'user' => $user];
}
Three things went wrong in one PR:
- a rename (
validateUser->registerUser) - a behaviour change (sending email became queueing email)
- a logging tidy-up somewhere else in the diff
A reviewer has to tease apart three concerns, and a bug in any one of them poisons the whole PR. If something breaks in production tomorrow, git bisect lands on "the rename commit" and nobody can tell which change actually caused it.
Worse: the rename probably wasn't complete. An UserService::validateUser string somewhere — in a route file, a queued job payload, a Blade view, a config array — is still pointing at a name that no longer exists.
After
The same intent, split across two tiny PRs. Each is boring. Neither can hurt you.
PR #1 — rename only. No behaviour change.
// PR title: "Rename UserService::validateUser to registerUser"
- public function validateUser(array $input): array
+ public function registerUser(array $input): array
{
// body unchanged
}
Plus the matching updates in every caller the IDE found, plus the one or two string references (routes, queue payloads, views) the IDE couldn't. The diff is long but mechanical, and every line is obviously a search-and-replace.
PR #2 — the actual behaviour change, against the honest name.
public function registerUser(array $input): array
{
// ...
- $this->mailer->send($user['email'], 'Welcome!');
+ $this->mailer->queue($user['email'], 'Welcome!');
// ...
}
Small. Targeted. Reviewable in under a minute.
Why this version is better
- a reviewer can approve the rename in seconds because nothing moved
- if production breaks,
git bisectlands on the behaviour PR, not a mixed diff - any missed reference is obvious because the rename PR is the only thing touching names
- the behaviour change gets reviewed on its own merit against a name that tells the truth
The rename checklist
Treat every rename as running through this list, even if it feels like overkill:
- Use the IDE's rename refactor, not find-replace. PHPStorm, VS Code with Intelephense, Rector — any of them catch more than a regex will. They understand types, method signatures, and scope.
- Search the codebase for the string form afterwards.
rg -w validateUserwill find references in strings, comments, views, SQL, and config that the IDE ignored. - Check the places the IDE can't see:
- Blade/Twig templates referencing method names via
$obj->method()on a generic variable - queued job class names stored in the jobs table or Redis
- event listener class name strings in config
- route names, middleware aliases, service container bindings
- route URLs themselves (a rename might change URLs)
- database column names (if the field is also renamed)
- API response keys documented elsewhere
- log search queries your ops team relies on
- Blade/Twig templates referencing method names via
- Run the full test suite. Renames should be a no-op for behaviour; tests should stay green without edits. If a test needs editing, check whether it was relying on the old name by accident.
- Commit the rename on its own. One PR, one rename, no other changes.
- Merge quickly. A long-lived rename branch collects merge conflicts in exactly the places a human is most likely to miss.
Renames with an external contract
Internal renames are cheap. Renames with an external contract are expensive and need a different strategy.
Do not just rename when the symbol is:
- a public class or function in a library or package consumed elsewhere
- a route URL used by a mobile app, a partner, or a saved link
- a database column used by reports, BI tools, or other services
- a queue job class name with messages already in flight
- a published event name subscribed to by another service
For those, use a two-step deprecation pattern:
<?php
final class UserService
{
public function registerUser(array $input): array
{
// real implementation
}
/**
* @deprecated since 2.4.0, use registerUser() instead. Will be removed in 3.0.
*/
public function validateUser(array $input): array
{
return $this->registerUser($input);
}
}
The old name still works. Callers get a deprecation warning. At the next major release, delete the old method. Nobody is surprised.
For routes, keep the old URL redirecting to the new one until callers have moved (a permanent redirect in your web server or framework router is enough).
For DB columns, use a two-stage migration: add the new column, backfill, switch writers, switch readers, then drop the old column in a later release.
Git blame survives a rename
A common objection to renaming is "it will ruin blame". It won't, as long as you use the rename on its own commit. git log --follow and git blame handle same-file renames and most method renames cleanly, and GitHub's blame UI has a "view blame prior to this change" button that makes historical context a single click away.
What does ruin blame is reformatting every file while renaming, or bundling the rename with ten unrelated tidy-ups. One more reason to keep the commit boring.
The one-minute sanity check
Before you hit merge on a rename:
- does the diff contain any behaviour changes? (if yes, split)
- does a grep for the old name return zero results? (if no, you missed some)
- does the test suite pass without editing any tests? (if no, investigate)
- is any caller of this symbol outside your repo? (if yes, use the deprecation path)
Four questions, one minute, and renames stop being the scary refactor.
From Writing decent PHP — Naming variables, functions, classes, and files well