Deprecations and Upgrades

A deprecation notice names a line that the next major version will break; the deprecation log is therefore the upgrade plan.

Syntaxtrigger_deprecation(string $package, string $version, string $message, mixed ...$args): void

A deprecation is a warning that something still works but will be removed. Symfony emits one as a silenced E_USER_DEPRECATED notice saying which package deprecated what, in which version, and what to use instead. Nothing fails. That is the point: you get a list before you get an outage.

The log is the plan

Symfony 8.1 routes deprecations to a dedicated log channel called deprecation, configured by the Monolog recipe. In dev they also appear in the web debug toolbar and on the Logs page of the profiler. Every distinct message is one upgrade task. When the log is empty, the major upgrade is mostly a version bump.

Minor, then major

Upgrade to the last minor release of your current major first, because deprecations are added in minors. Each major branch has five minors (8.0 to 8.4), so Symfony 8.4 is the one that tells you everything Symfony 9.0 will remove; Symfony 8.0 cannot. Then change the constraints and run composer update "symfony/*", and read UPGRADE-9.0.md for the breaks that were never deprecated.

Deprecating your own code

trigger_deprecation(), from the symfony/deprecation-contracts package, is a thin wrapper: it prepends Since <package> <version>: to your message and calls @trigger_error($message, \E_USER_DEPRECATED). Both forms end up in the same log. PHP 8.4 added the #[\Deprecated] attribute, which raises the same kind of notice when a marked function or method is called.

Example

Example · php
// src/Service/InvoiceNumberer.php
declare(strict_types=1);

namespace App\Service;

final class InvoiceNumberer
{
    public function next(int $year, int $sequence): string
    {
        return sprintf('INV-%d-%04d', $year, $sequence);
    }

    /**
     * @deprecated since acme/billing 2.4, use next() instead
     */
    public function nextNumber(int $year, int $sequence): string
    {
        trigger_deprecation(
            'acme/billing',
            '2.4',
            'Calling "%s()" is deprecated, use "next()" instead.',
            __METHOD__,
        );

        // The call above is this, with the "Since ..." prefix added for you.
        // The @ matters: it silences the notice so it never reaches output,
        // while an error handler can still see and log it.
        //
        //   @trigger_error(
        //       'Since acme/billing 2.4: Calling "'.__METHOD__.'()" is '
        //       .'deprecated, use "next()" instead.',
        //       \E_USER_DEPRECATED,
        //   );

        return $this->next($year, $sequence);
    }
}

// Output, on the "deprecation" log channel:
// Since acme/billing 2.4: Calling "App\Service\InvoiceNumberer::nextNumber()"
// is deprecated, use "next()" instead.

When to use it

  • Turn an upcoming major upgrade into a finite list of tasks by reading the deprecation log.
  • Remove a method from a shared internal package without breaking the applications that still call it.
  • Keep a test suite from accumulating new deprecations by failing the build when the count rises.
  • Decide whether a dependency is maintained: check whether its deprecations name a replacement.

More examples

Where deprecations are logged

This is close to what the Monolog recipe installs. The separate handler is what makes deprecations countable in production: one JSON line per notice on stderr, which a log collector can group by message. Reach for this file when the toolbar shows deprecations in dev but production looks clean.

Example · yaml
# config/packages/monolog.yaml
monolog:
    channels:
        - deprecation

when@dev:
    monolog:
        handlers:
            main:
                type: stream
                path: "%kernel.logs_dir%/%kernel.environment%.log"
                level: debug
                channels: ["!event"]

when@prod:
    monolog:
        handlers:
            main:
                type: fingers_crossed
                action_level: error
                handler: nested
                # Deprecations are deliberately kept out of the main handler.
                channels: ["!deprecation"]
                buffer_size: 50
            nested:
                type: stream
                path: php://stderr
                level: debug
                formatter: monolog.formatter.json
            deprecation:
                type: stream
                channels: [deprecation]
                path: php://stderr
                formatter: monolog.formatter.json

Failing the test suite when the count grows

symfony/phpunit-bridge collects deprecations during a test run and prints them grouped by message with a count and the tests that caused them. Setting max[self]=0 with a baseline is the practical setting: existing debt is tolerated, new debt is a red build. Install it with composer require --dev symfony/phpunit-bridge.

Example · xml
<!-- phpunit.dist.xml -->
<phpunit>
    <php>
        <!-- max[self] = deprecations triggered by your own code.
             A baseline file records the ones you already have, so only
             new ones fail. Generate it once with generateBaseline=true. -->
        <env name="SYMFONY_DEPRECATIONS_HELPER"
             value="max[self]=0&amp;baselineFile=./tests/allowed.json"/>
    </php>
</phpunit>

Moving the version constraint in one place

Symfony Flex reads extra.symfony.require and applies it to every symfony/* package, so a major upgrade edits one string instead of thirty. Then run composer update "symfony/*" (add --with-all-dependencies if third-party packages block it) and clear var/cache before booting.

Example · json
{
    "require": {
        "php": ">=8.5",
        "symfony/console": "*",
        "symfony/framework-bundle": "*",
        "symfony/runtime": "*"
    },
    "extra": {
        "symfony": {
            "require": "8.1.*"
        }
    }
}

Discussion

  • Be the first to comment on this lesson.