- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You renamed a parameter. You tightened a return type from iterable to array because it was always an array anyway. You tagged 2.4.1, pushed, and went to lunch. By the time you got back, three downstream projects had red CI and one of them was a framework used by thousands of apps.
None of that felt like a breaking change while you were writing it. That's the problem with backward compatibility: the breaks that hurt are the ones that look harmless in the diff. A human reviewer skims past them. Composer's ^2.0 constraint trusts your version number, and your version number said "patch."
The fix is not more discipline. It's a tool that reads your public API the way composer does and refuses to let a break through without a major bump. That tool is Roave BackwardCompatibility Check.
What actually counts as a BC break
A backward-compatibility break is any change to your public API that can make working consumer code stop working. "Public" means everything reachable from outside your package: public and protected methods, public and protected properties, class constants, function signatures, and the classes and interfaces themselves.
Here are the changes that break consumers, and most of them are easy to ship by accident:
- Removing a public method, property, constant, class, or interface.
- Renaming anything public. A rename is a remove plus an add.
- Adding a required parameter to a public method. Every existing call site is now short an argument.
-
Narrowing a parameter type.
string $idtoUuidInterface $idbreaks every caller that passed a string. -
Widening a return type.
arraytoiterablebreaks callers that indexed the result or counted it. -
Reducing visibility.
publictoprotected, orprotectedtoprivate. -
Making a non-final class
final. Every subclass in the wild stops loading. - Adding a method to an interface consumers implement.
The rule underneath all of these is Liskov substitution. Consumers wrote code against a contract. If the new version can't stand in for the old one everywhere the old one was valid, you broke it.
What does not count
The mirror image trips people up just as often. These changes are safe, and treating them as breaking is how libraries end up stuck on a version they hate.
- Adding a new public method, property, or constant. Nobody depended on its absence.
- Adding an optional parameter with a default value.
-
Widening a parameter type.
UuidInterface $idtostring|UuidInterface $idaccepts everything it used to, plus more. -
Narrowing a return type.
iterabletoarraystill satisfies any caller that expectediterable. -
Anything
private. Internal fields, private methods, implementation details. Consumers can't reach them, so you can rewrite them freely.
Parameters are contravariant, returns are covariant. Accept more, return less specific for inputs and more specific for outputs. Get the direction backwards and a "safe cleanup" becomes a major version.
This is also why the boundary between public and private is the single most important design decision in a library. Everything you expose is a promise. Everything you hide is yours to change. A checker only helps if that line is drawn on purpose.
Installing and running the check
Add it as a dev dependency:
composer require --dev \
roave/backward-compatibility-check
The binary compares two points in your git history. By default it finds your latest release tag and diffs it against your current working state:
vendor/bin/roave-backward-compatibility-check
Under the hood it uses roave/better-reflection to read both versions of your API without running your code, then applies a catalogue of detectors for the break categories above. No autoloading of the consumer, no runtime, no test suite. Pure static comparison of two API surfaces.
You can pin the endpoints explicitly, which is what you want in CI:
vendor/bin/roave-backward-compatibility-check \
--from=2.4.0 --to=HEAD
--from and --to take any git reference: a tag, a branch, a commit SHA.
Reading the output
Say you changed a repository interface like this:
interface OrderRepository
{
// was: public function find(string $id): ?Order;
public function find(UuidInterface $id): ?Order;
// new method added to the interface
public function findByCustomer(
UuidInterface $customerId
): array;
}
The checker reports each finding with a SemVer severity:
[BC] CHANGED: The parameter $id of
OrderRepository#find() changed from string
to a non-contravariant UuidInterface
[BC] ADDED: Method findByCustomer() was added
to interface OrderRepository
2 backwards-incompatible changes detected
Two breaks. The parameter narrowed, and adding a method to an interface breaks every class that implements it. The command exits with a non-zero status, which is the part CI cares about.
Telling it what is not public
Reflection sees everything. It doesn't know that Internal\Hydrator is an implementation detail you never promised to keep. Mark those symbols with an @internal docblock tag and the checker skips them:
/**
* @internal This class is not covered by the
* backward-compatibility promise.
*/
final class Hydrator
{
// free to change across any release
}
Draw this boundary early. A library that forgot to mark its internals gets punished for refactoring its own guts, then either lies about SemVer or freezes. Both are worse than a one-time annotation pass.
PHP 8.4 gave you a second signal for the deprecation half of the lifecycle. Before you remove anything, mark it with the native #[\Deprecated] attribute so consumers get a runtime notice one minor version ahead of the removal:
#[\Deprecated(
message: 'Use find() instead',
since: '2.5.0',
)]
public function findOne(string $id): ?Order
{
return $this->find($id);
}
Adding the attribute is not itself a break. Removing the method later is, and by then your users have had a release cycle of warnings to migrate.
Gating it in CI
The check earns its keep on pull requests. Run it before merge, and a break can't reach a tag without someone deciding to cut a major version on purpose.
The one thing that trips up first-time setups: the tool needs full git history and your release tags. Shallow checkouts hide the baseline.
# .github/workflows/bc.yml
name: Backward compatibility
on:
pull_request:
paths:
- 'src/**'
- 'composer.json'
jobs:
bc-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install
run: composer install --no-progress
- name: Check BC
run: >
vendor/bin/roave-backward-compatibility-check
--format=github-actions
The github-actions format turns each finding into an inline annotation on the changed line, so the reviewer sees "this narrows a parameter type" right in the diff. There's also --format=markdown if you'd rather post a summary comment.
The gate is deliberately dumb: any break fails the build. That is the point. It doesn't stop you from breaking things. It stops you from breaking things quietly. When the job goes red, you make a decision. Either you undo the break, or you accept it and the next tag is a major. Either way it was a choice, not an accident discovered by a stranger's failing pipeline.
The discipline it buys you
SemVer is a promise about your version numbers: patch fixes bugs, minor adds features, major breaks things. Most libraries make that promise and keep it by hand, which means they keep it right up until the day someone's tired on a Friday.
Roave BackwardCompatibility Check moves the promise from your memory to your CI. The API surface becomes something the machine watches, the same way PHPStan watches your types. You stop relying on remembering that tightening a return type is a break, because the build remembers for you.
That's the whole value. Breaks don't become impossible. They become visible before they ship, while they're still cheap to reconsider.
If this was useful
A backward-compatibility checker only works when there's a clear line between the API you promise and the internals you own. That line is exactly what hexagonal architecture draws on purpose: ports are the public contract, adapters are the disposable inside. Keep the promise thin and at the edge, and both your @internal annotations and your refactoring freedom follow from the shape of the code. That boundary discipline is what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)