DEV Community

Cover image for How to deprecate PHP code without breaking your users
Roberto B.
Roberto B.

Posted on

How to deprecate PHP code without breaking your users

Removing old code from your project or library without breaking existing users is tricky. This guide shows you how to deprecate PHP methods, classes, and parameters the right way.

If you maintain a library, an SDK, or even a shared internal package, you eventually hit this moment:

You realize that something you shipped months (or years) ago is no longer the right API.

The abstraction may be wrong. The constructor should replace a factory. Maybe a “convenience” method turned out to be a long-term design mistake.

Whatever the reason, the uncomfortable truth is:

Good APIs evolve. And evolution means change.

But changing public APIs is dangerous. People build businesses on top of them. Coworkers build systems on top of them. Breaking them casually is how you lose trust.

Over time, I’ve adopted the same approach used by some open-source PHP projects: deprecate first, remove later.

This is how I do it in practice.

What deprecation actually means

When I mark something as deprecated in my code in my projects, I’m not immediately deleting it. I’m saying:

  • The method still works
  • The method is still callable
  • The users are warned that they should stop using it
  • And I have a proper way to tell users what to use instead

Deprecation is a promise that defines a migration path. In other words, it is a way to say “This still works today, but it won’t forever. Here’s how to migrate.”

This gives your users time. It gives you freedom to evolve. And it makes your API predictable and trustworthy.

This is a good practice, also used in other programming languages. I remember when I used Java (using the @deprecated tag in the JavaDoc comments) or the "deprecated" decorator in Python. Also, in the PHP world, the Symfony ecosystem uses this practice. If you’ve ever upgraded a Symfony or Laravel app across major versions, you’ve benefited from this discipline, whether you noticed it or not.

A real example from an SDK

Let’s say I originally had an API like this:

$client->assetApi($spaceId);
Enter fullscreen mode Exit fullscreen mode

Later, I realize that a more explicit and composable API is better:

new AssetApi($managementApiClient, $spaceId);
Enter fullscreen mode Exit fullscreen mode

I don’t want to break existing users. But I do want to push everyone toward the new design.

So I deprecate the old method.

What a properly deprecated method looks like

Here’s the pattern I use (and yes, this is basically what some other PHP open-source projects do internally):

/**
 * @deprecated since 1.1, use new AssetApi($managementApiClient, $spaceId) instead
 * @codeCoverageIgnore
 */
public function assetApi(string|int $spaceId): AssetApi
{
    trigger_error(
        'Method assetApi() is deprecated since 1.1 and will be removed in 2.0. '
        . 'Use new AssetApi($managementApiClient, $spaceId) instead.',
        E_USER_DEPRECATED
    );

    return new AssetApi($this, $spaceId);
}
Enter fullscreen mode Exit fullscreen mode

This combines three layers:

  • PHPDoc @deprecated, IDEs and static analysis tools pick this up immediately
  • trigger_error() with E_USER_DEPRECATED, emits a runtime warning so users see it in logs or test output
  • @codeCoverageIgnore, optional, but useful if your deprecated code isn't worth covering

Let’s break down why this is such a powerful pattern.

1. The @deprecated PHPDoc tag (for humans and tools)

/**
 * @deprecated since 1.1, use ...
 */
Enter fullscreen mode Exit fullscreen mode

This PHPDoc tag is not just documentation:

  • IDEs will highlight usages
  • Static analyzers (PHPStan, Psalm) will warn
  • Code reviewers will see it immediately

This is your "development-time" signal.

2. Triggering a runtime deprecation

trigger_error(..., E_USER_DEPRECATED);
Enter fullscreen mode Exit fullscreen mode

Why it matters:

  • Users see deprecation warnings in development
  • CI can fail on deprecations if configured
  • The tests can raise a warning or fail on deprecation according to the test framework configuration
  • It makes deprecated usage visible even if nobody reads the docs

3. Always tell people what to use instead

'deprecated since 1.1 and will be removed in 2.0', use new AssetApi(...) instead.'
Enter fullscreen mode Exit fullscreen mode

A deprecation without a migration path is just frustration for users.

A good deprecation message answers:

  • What is deprecated?
  • Since when?
  • When will it be removed?
  • What should I use instead?

This builds trust and makes upgrades predictable.

4. The deprecated method becomes a thin compatibility layer

When you deprecate a method, the worst thing you can do is copy its old logic in the new refactored method and keep maintaining it in parallel.

Instead, whenever possible, the deprecated method should simply delegate to the new implementation.

In this example, the deprecated method just uses the new initialization logic:

return new AssetApi($this, $spaceId);
Enter fullscreen mode Exit fullscreen mode

This has several significant benefits:

  • No duplicated logic to maintain
  • No risk of subtle behavioral differences between old and new code
  • Any bug fix or improvement automatically applies to both paths

In practice, the old API becomes a thin compatibility layer that forwards to the new one.

This is the strategy used by other PHP open-source projects to preserve backward compatibility without freezing the architecture in time.

PHP 8.4+: the native #[Deprecated] attribute

If your project uses PHP 8.4 or later, you can simplify this using the native #[Deprecated] attribute:

/**
 * @deprecated since 1.1, use new AssetApi($managementApiClient, $spaceId) instead
 * @codeCoverageIgnore
 */
#[Deprecated(
    message: 'use new AssetApi($managementApiClient, $spaceId) instead',
    since: '1.1'
)]
public function assetApi(string|int $spaceId): AssetApi
{
    return new AssetApi($this, $spaceId);
}
Enter fullscreen mode Exit fullscreen mode

The benefit? No manual trigger_error(), PHP automatically emits E_USER_DEPRECATED when the method is called. Cleaner code, same result.

Keep the PHPDoc @deprecated tag alongside the attribute for full IDE and documentation support if the IDE you are using does not yet support the PHP Deprecated attribute.

PHPUnit and deprecations: some practical ways to deal with them

When you run your test suite with:

vendor/bin/phpunit
Enter fullscreen mode Exit fullscreen mode

and a deprecated method is executed, PHPUnit will highlight it with a D, usually in yellow instead of green. That’s PHPUnit telling you: “Something here is using a deprecated API.”

Sometimes that’s exactly what you want. But depending on your workflow and your goals, you have a few different options.

Option 1: let PHPUnit show the D (the default)

The simplest approach is to do nothing.

Just run:

vendor/bin/phpunit
Enter fullscreen mode Exit fullscreen mode

and let PHPUnit mark every deprecation with a yellow D.

This is useful because:

  • Deprecations are immediately visible
  • You can see if they are increasing over time
  • They act as a constant reminder that some migration is still pending

For many projects, this is already good enough.

Option 2: show exactly where deprecations come from

If you want more details about which code is triggering the deprecation, you can run:

vendor/bin/phpunit --display-deprecations
Enter fullscreen mode Exit fullscreen mode

This will show:

  • The exact deprecation messages
  • The file and line number
  • Which test caused it

This is extremely useful when you’re actively cleaning up deprecated usage or during an upgrade.

Option 3: fail the test suite on deprecations

If you want to treat any usage of deprecated code as a test failure, PHPUnit can do that for you.

Enabling the failOnDeprecation option causes the test suite to fail immediately whenever a deprecation is triggered during the test run. This is a very strict mode, but it’s extremely effective at preventing the accidental use of deprecated APIs.

To enable it, add failOnDeprecation="true" to the <phpunit> element in your phpunit.xml or phpunit.xml.dist file:

<?xml version="1.0" encoding="UTF-8" ?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.0/phpunit.xsd"
    bootstrap="vendor/autoload.php"
    colors="true"
    cacheDirectory=".phpunit.cache"
    failOnDeprecation="true"
>
Enter fullscreen mode Exit fullscreen mode

This gives you a very strong guarantee:

If the test suite is green, your code is not using any deprecated APIs.

In practice, this is especially useful for application code or libraries that want to enforce the use of new APIs strictly. For projects that still intentionally test deprecated behavior for backward compatibility, this mode can be too strict, but when you want zero tolerance, it's a great tool.
I use this when my goal is to reduce and eventually remove deprecated code from the codebase. Failing tests serve as a guide, highlighting exactly where deprecated code is still being called.

Option 4: remove the tests and ignore coverage for deprecated code

In some cases, especially in libraries and SDKs, tests also serve as documentation.

I know this approach is a bit opinionated, but I also want to share this option with you.

If you don’t want deprecated APIs to appear in your examples anymore, you can:

  • Remove the tests that exercise the deprecated methods
  • Keep only tests for the new API

At that point, the deprecated method will naturally show up as uncovered code in your coverage report. If you want to keep coverage clean and honest, you can explicitly mark it as:

/**
 * @codeCoverageIgnore
 */
Enter fullscreen mode Exit fullscreen mode

This is you saying:

“This code exists only for backward compatibility. It’s not part of the active API anymore.”

There’s no single right choice

Sometimes you want visibility. Sometimes you want strictness. Sometimes you want a clean test suite that only shows the modern API.

All these approaches are valid; the critical part is to choose one intentionally, instead of letting deprecations become invisible background noise.

The deprecation lifecycle

Over time, I’ve learned that the most essential quality of a good deprecation policy is predictability. Users don’t want surprises, and they definitely don’t want to discover breaking changes by accident.

That’s why I try to keep the deprecation lifecycle simple, and predictable, for example in my last project I had:

  • In 1.1, the method is introduced as deprecated
  • In 1.x, the method still works, but it emits a warning when used
  • In 2.0, the method is removed entirely

This kind of timeline gives users plenty of time to adapt. It makes upgrades safer, avoids unpleasant surprises, and lets people plan migrations rather than react to sudden breakages. That’s what makes a library or platform feel reliable and professional over the long term.

When I don’t bother deprecating

If a method is new, not public, not documented, and not used by anyone, I just delete it.

Deprecation is a promise you make to users, and if there are no users, there’s no promise to keep.

I reserve deprecation for things that are actually part of a contract: public APIs, libraries, frameworks, SDKs, and shared internal platforms.

Why this matters more than you think

I care a lot about deprecation discipline because it changes the entire dynamic of how a project evolves.

When deprecations are correctly done, I can improve my APIs without fear, and users can upgrade without panic. The architecture doesn’t get stuck in the past, and the project builds long-term trust simply by being predictable.

Good deprecation practices are a quiet signal of engineering maturity. They make upgrades boring (and that’s a compliment), they remove drama from releases, and they let the codebase evolve without constantly worrying about breaking everything.

Bad deprecation practices do the opposite. They create upgrade anxiety, push users into version lock-in, and eventually force big, painful rewrites that nobody enjoys and nobody really has time for.

Additional note: how Symfony manages deprecations at scale

Large frameworks like Symfony face a challenge: as the codebase grows, deprecations multiply, and unmanaged deprecations become noise. Symfony addresses this with the symfony/phpunit-bridge package, which enhances PHPUnit with deprecation-aware behavior:

  • Captures all E_USER_DEPRECATED notices during test runs
  • Groups and summarizes them for analysis
  • Can enforce limits or baselines via the SYMFONY_DEPRECATIONS_HELPER environment variable (e.g., max[direct]=0 to fail on any new direct deprecation)

The guiding principle: existing deprecations are tolerated, but new ones must not slip in unnoticed.

This turns deprecations into measurable technical debt, the count should shrink over time, never grow silently.

You don't need the full PHPUnit bridge for most projects. Simply configuring your tests to fail on deprecations (e.g., failOnDeprecation="true" in PHPUnit 10+) is a practical first step toward disciplined API evolution.

Final Thought

Deprecation is not about the past. It’s about making the future safe.

If you treat deprecation as a first-class design tool, your codebase can grow for years without collapsing under its own history.

And that’s what great libraries do.

Top comments (0)