An apparently minor change to a shared PHP library can halt independent deliveries. Renaming a parameter, changing a default value, or replacing an exception can break a consumer that is not deployed today, lives in another repository, or invokes the component indirectly. The failure may appear at runtime, in an asynchronous job, or when deserializing data generated before the change.
Backward compatibility in PHP is not about preserving every historical interface. It is a discipline that allows producers and consumers to evolve at different rates, with an explicit migration window and verifiable removal. The goal is to avoid both forced coordinated deployments and the permanent accumulation of obsolete APIs.
Identify what is part of the internal contract

An internal contract is any behavior another module depends on, even if it is not published as an external API. Composer dependencies and PHP interfaces are a visible part, but they do not exhaust the scope. Before changing shared code, review at least these elements:
- Public signatures: method names, parameters, order, types, nullability, default values, and return type.
- Semantics: what each argument means, which fields are required, and what result is expected under a specific condition.
- Errors: thrown exceptions, error codes, messages processed by clients, and null or empty results.
- Data: array keys, JSON structures, queue messages, domain events, serialized files, and persisted data.
- Side effects: event dispatching, database writes, cache invalidation, HTTP calls, and execution order.
- Operational behavior: retries, idempotency, time limits, and handling of transient failures.
For example, adding a field to a JSON response is usually additive, but it ceases to be so if a consumer validates a closed list of properties. Likewise, a more specific exception may be technically correct but incompatible if the consumer catches the previous exception to trigger a recovery.
Classify the change before writing the implementation
Classification prevents a design decision from becoming a production incident. It is useful to document it in the change proposal, together with known consumers and the exit strategy.
Additive changes
They introduce a new capability without altering the existing path: a new method, an optional parameter with neutral semantics, an additional event, or a new version of a message. They are the preferred option when consumers are deployed separately. The new path must be able to coexist with the previous one, and the previous behavior must be preserved in a verifiable way.
Changes compatible through adaptation
They make it possible to preserve the previous result through a translation layer. For example, an old interface can delegate to a new service, converting arguments and results. Adaptation makes sense if it is localized, has a removal date, and does not hide a business difference that the consumer must consciously decide.
Incompatible or uncertain changes
Removing a method, tightening a type, changing the meaning of a state, or modifying a persisted format is usually incompatible. Any change without a reliable consumer inventory should also be treated as uncertain. In both cases, publishing a new package version is not enough: a transition, a planned migration, or a separate contract version is required.
Build a verifiable consumer inventory
Do not base the decision on text searches alone. A component can reach another through a dependency container, configuration, reflection, events, queues, or an HTTP integration. The inventory must combine static evidence and representative execution.
- Review dependencies declared in Composer, version constraints, and repositories that install the package.
- Search for direct uses of classes, interfaces, methods, events, configuration keys, and message formats.
- Inspect factories, container definitions, listeners, commands, cron jobs, workers, and infrastructure adapters.
- Identify critical paths: payments, authentication, orders, synchronization, notifications, and recovery processes.
- Record the owner, version in use, migration path, and evidence that the change was completed for each consumer.
Publishing a library and deploying an application are different actions. Publishing a compatible version allows each consumer to update when it is ready; deploying all consumers simultaneously turns an ordinary evolution into a fragile organizational dependency.
Apply additive evolution and adapters at the right boundary
When a new requirement changes the model, first introduce a new capability and temporarily retain the previous one. A legacy interface can delegate to the new implementation, provided that the conversion is unambiguous. This allows consumers to migrate without having to coordinate a single window.
interface LegacyPriceCalculator
{
public function calculate(int $amount): int;
}
final class LegacyPriceCalculatorAdapter implements LegacyPriceCalculator
{
public function __construct(private PriceCalculator $calculator) {}
public function calculate(int $amount): int
{
return $this->calculator->calculate(new Money($amount, 'EUR'))->amount();
}
}The adapter normally belongs at the boundary between contracts, not in the domain core. The domain should express the current model; the translation of old arguments, sentinel values, or historical formats should remain in a dedicated layer. If the domain retains conditions for each generation of clients, historical complexity spreads to every future change.
Do not force an adapter when there is information loss or a new business decision. If the old contract does not contain the data required for the new behavior, retain both contracts during the transition or explicitly request the additional information from the consumer.
Turn deprecation into managed removal
An API marked as obsolete without an alternative, deadline, or owner is not a deprecation: it is untracked debt. A useful removal must include a signal in the code, migration instructions, a removal condition, and usage observation where possible.
- Mark the legacy method or class with clear documentation and, where appropriate, emit a controlled warning with
trigger_error(..., E_USER_DEPRECATED). - Specify the exact alternative, including differences in semantics, errors, and default values.
- Define a verifiable exit condition: all inventoried repositories migrated, no observed calls, or end of support for a specific version.
- Assign an owner to review progress and remove the layer when the condition is met.
Avoid emitting indiscriminate warnings on high-volume paths without an aggregation strategy: noise can obscure relevant signals and increase operational cost. Observability must answer a specific question: which consumers still use the previous contract and on which path.
Test the transition and execute the delivery sequence
Unit tests for the component alone do not prove that consumers continue to work. Add contract tests for the inputs, outputs, and errors each consumer requires. Maintain regression cases for the old interface while it is supported, and explicitly test missing values, previous serialized payloads, and expected exceptions.
The safe sequence usually follows this order:
- Publish the new contract or additive implementation while retaining the previous path.
- Update and deploy consumers independently, using integration tests where the risk justifies them.
- Observe errors, deprecation warnings, and use of the legacy interface.
- Confirm the migration inventory and address detected indirect consumers.
- Remove the adapter or old contract in a separate delivery, with tests that confirm its absence.
Checklist for approving the change

- Is the affected contract defined beyond the PHP signature?
- Is the change classified as additive, adaptable, incompatible, or uncertain?
- Is there a consumer inventory, including events, data, and indirect paths?
- Does the solution avoid requiring simultaneous deployments?
- Is the adapter, if any, outside the domain and scheduled for removal?
- Have the previous behavior, new capability, and expected errors been tested?
- Does the deprecation specify an alternative, removal condition, and owner?
- Is there a signal to detect hidden dependencies before removing the API?
The right decision is neither to maintain compatibility indefinitely nor to impose total coordination. It is to design a bounded transition: preserve what is necessary, migrate with evidence, and remove historical compatibility when it no longer provides safety.



