Find PHP Deprecations in Your Codebase Before Users Do

Find PHP deprecations before upgrading with linting, PHPCompatibility, PHPStan, runtime logging, and CI checks to prevent unexpected production failures.

PHP code editor showing deprecation warnings alongside linting, static analysis, runtime logging, and CI checks.

Short answer: to find PHP deprecations in your codebase, run three passes, because no single tool sees all of them. First, lint every file with the PHP version you are moving to (php -l), which surfaces compile-time deprecations. Second, run static analysis: PHPCompatibility for changes in PHP itself, and PHPStan’s deprecation rules for @deprecated library code. Third, log E_DEPRECATED and E_USER_DEPRECATED at runtime with your own error handler, because the production php.ini hides them by default. Then make CI fail when a new one appears.

Everything below targets PHP 8.1 or later, Composer 2.x and PHPUnit 11 or 12. At the time of writing, the php.net supported versions page lists PHP 8.5 (released 20 November 2025) as the newest branch, and PHP 8.2 leaves security support on 31 December 2026. If your server runs 8.2, this is the year to look.

Why deprecations hide until the upgrade

A deprecation is not an error. The code still runs, the page still renders, and nothing in your logs changes unless you asked for it. That is the trap. The shipped php.ini-production file sets error_reporting = E_ALL & ~E_DEPRECATED, so on most production servers every deprecation notice is thrown away before it reaches a log.

Then the upgrade lands. The feature that was “only deprecated” is removed, and the first person to find out is a visitor staring at a fatal error. Finding deprecations early is cheap: a few commands, one small PHP file and a CI flag. Finding them after a host forces an upgrade costs a weekend.

Two kinds of deprecation, and why one tool never catches both

Most guides treat “deprecation” as one thing. It is two, raised by different code, and each detection method is blind to part of the picture.

KindWho raises itExampleWhat finds it
Engine, compile timePHP, while compiling a fileImplicitly nullable parameter function f(Foo $x = null) (8.4)php -l on the target version, PHPCompatibility
Engine, runtimePHP, while running a linePassing null to strlen() (8.1), dynamic properties (8.2)Runtime logger, tests
Library, runtimeA framework via trigger_error(..., E_USER_DEPRECATED)WordPress _deprecated_function(), Symfony trigger_deprecation()Runtime logger, tests
Library, docblock onlyA @deprecated tag that raises nothingA method marked for removal in the next majorPHPStan deprecation rules, your IDE

Read the table by column, not by row. Static analysis cannot know that a variable holds null at runtime. A runtime logger cannot see a docblock. A linter only sees what the compiler sees. That is why the answer is three passes and not one favorite tool.

Pass 1: lint every file with the PHP version you are moving to

The PHP linter compiles each file without running it, and compiling is exactly when the engine raises compile-time deprecations. The catch: you must run the target version, not the one on your server today.

If you have the target binary installed (Debian and Ubuntu packages often name it php8.4):

find src -name '*.php' -print0 \
  | xargs -0 -n1 php8.4 -d error_reporting=-1 -d display_errors=1 -l 2>&1 \
  | grep -i deprecated

If you do not, use the official Docker image so nothing on the machine changes:

docker run --rm -v "$PWD":/app -w /app php:8.4-cli sh -c \
  "find src -name '*.php' -exec php -d error_reporting=-1 -d display_errors=1 -l {} \; 2>&1 | grep -i deprecated"

Any line it prints names the file and line. The implicitly nullable parameter deprecation added in PHP 8.4 is the classic hit, and the fix is the explicit form ?Foo $x = null. What this pass cannot see is anything that depends on values, so strlen(null) sails straight through.

Pass 2: static analysis for engine changes and @deprecated code

PHPCompatibility for changes in PHP itself

PHPCompatibility is a PHP_CodeSniffer standard that checks your code against a range of PHP versions without running it. Its README currently installs the 10.0 development line:

composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev phpcompatibility/php-compatibility:"^10.0.0@dev"
vendor/bin/phpcs -ps src --standard=PHPCompatibility --runtime-set testVersion 8.4-

testVersion 8.4- means “8.4 and everything after it”. Run it against src first, not the whole project, or the report fills with vendor code you do not own. The README is honest that coverage “is not yet 100%”, so treat a clean run as a good sign, not a guarantee.

PHPStan deprecation rules for library code

The phpstan/phpstan-deprecation-rules extension reports every use of a class, method, property, constant or trait marked @deprecated. This is the only pass that catches docblock-only deprecations, which are often the next major version of your framework telling you what it is about to delete.

composer config allow-plugins.phpstan/extension-installer true
composer require --dev phpstan/phpstan phpstan/extension-installer phpstan/phpstan-deprecation-rules
vendor/bin/phpstan analyze src --level=1

With the extension installer present, the rules load without editing any config. Start at a low level so deprecation findings are not buried under unrelated type errors. PhpStorm users get a similar signal in the editor, where deprecated calls are drawn with a strikethrough, and running Code, Inspect Code over the project lists them in one place.

Rector fixes, it does not only find

Rector is often suggested here, and it is useful after you have found things. vendor/bin/rector process --dry-run shows the diff it would apply, and an empty withPhpSets() in rector.php reads the PHP version from the require.php constraint in composer.json. That means you raise the constraint first, then let Rector propose the mechanical fixes, then review every hunk before committing.

Pass 3: log deprecations at runtime, including the silenced ones

Runtime logging catches what depends on real data: nulls reaching internal functions, dynamic properties set on a live request, a plugin calling a function WordPress retired. Four details decide whether your logger actually works, and they are the part most guides skip.

  1. The production mask does not stop you. The PHP manual page for set_error_handler, read on 23 September 2026, states that for the error types you pass in its mask, error_reporting() settings have no effect and your handler is called regardless.
  2. Libraries silence their own deprecations. Symfony’s trigger_deprecation() raises its notice with the @ operator, so it stays off your screen. The PHP manual page on error control operators, read the same day, confirms a custom handler is still called when @ is used, and that since PHP 8.0, error_reporting() inside the handler returns a mask of fatal error types instead of 0. A handler that begins with “if this type is not in error_reporting(), ignore it” therefore throws away every silenced library deprecation. Do not copy that line.
  3. The reported line is usually the wrong line. For E_USER_DEPRECATED, PHP reports where trigger_error() sits inside the library, not where your code called the deprecated function. It is the same problem described in why the error line is not where the bug is, and the fix is to walk the backtrace to your own code.
  4. OPcache hides compile-time repeats. With OPcache on, a file is compiled once and then served from cache, so its compile-time deprecations appear once and vanish from the log. PHP 8.0 added opcache.record_warnings (default 0) to replay them from cache. Turn it on in staging, or rely on Pass 1 for those.

Here is a logger that handles all four. It needs PHP 8.1 or later.

<?php
// deprecation-logger.php (PHP 8.1+). Load it before anything else.
declare(strict_types=1);

set_error_handler(
    static function (int $errno, string $errstr, string $errfile, int $errline): bool {
        static $seen = [];

        // Library deprecations: blame the first frame in YOUR code.
        $where = $errfile . ':' . $errline;
        if ($errno === E_USER_DEPRECATED) {
            $skip = ['/vendor/', '/wp-includes/'];
            foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) {
                $file = str_replace('\\', '/', $frame['file'] ?? '');
                if ($file === '') {
                    continue;
                }
                foreach ($skip as $dir) {
                    if (str_contains($file, $dir)) {
                        continue 2;
                    }
                }
                $where = $file . ':' . ($frame['line'] ?? 0);
                break;
            }
        }

        // One log line per unique message and location per request.
        $key = $errno . '|' . $errstr . '|' . $where;
        if (!isset($seen[$key])) {
            $seen[$key] = true;
            $kind = $errno === E_DEPRECATED ? 'php' : 'library';
            error_log(sprintf('[deprecation:%s] %s at %s', $kind, $errstr, $where));
        }

        return true; // handled: never shown to visitors
    },
    E_DEPRECATED | E_USER_DEPRECATED
);

Load it first with auto_prepend_file=/path/to/deprecation-logger.php in php.ini, a PHP-FPM pool config, or .user.ini, whichever your host allows. Adjust $skip to match your stack; /wp-includes/ is there so WordPress core frames are skipped and the plugin or theme that made the call gets the blame. Returning true keeps notices off the page, which matters on a live site. On WordPress you can pair this with WP_DEBUG_LOG without displaying errors to visitors, since _deprecated_function() only triggers its notice when WP_DEBUG is on.

After a day of real traffic, turn the log into a ranked list:

grep -o '\[deprecation:[a-z]*\] .*' /path/to/php-error.log | sort | uniq -c | sort -rn | head -20

The path is whatever your error_log ini setting points to. The top of that list is your work queue, and the at location tells you which file to open. If the log is a WordPress debug log mixed with other noise, the method in reading a WordPress debug log and naming the guilty code applies line for line.

Pass 4: make CI fail on new deprecations

Finding deprecations once is a project. Keeping them from coming back is a setting. PHPUnit can fail a green test run when any test triggers E_DEPRECATED or E_USER_DEPRECATED:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         displayDetailsOnTestsThatTriggerDeprecations="true"
         failOnDeprecation="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source ignoreSuppressionOfDeprecations="true">
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

ignoreSuppressionOfDeprecations is the test-suite version of point 2 above: it reports E_USER_DEPRECATED notices even when a library silenced them with @. Run the suite on both your current PHP version and the target one in your CI matrix, and add the Pass 1 and Pass 2 commands as separate jobs.

What these tools will not tell you

Be clear about the gaps, because a clean report invites false confidence.

  • Dynamic code is invisible to static tools. call_user_func($name), variable function names and magic methods resolve at runtime, so only the logger or a test can see them.
  • Runtime only sees paths that ran. A deprecated call inside a yearly report or an admin screen nobody opened stays silent until someone opens it. Tests widen coverage; traffic alone does not.
  • AI coding assistants lag the changelog. A model trained before a release does not know what that release deprecated, and it will still suggest functions PHP has already removed, such as create_function() (gone since PHP 8.0). Treat generated PHP as unverified, check suspicious calls the way you would check whether an AI-invented function exists, and trust the php.net migration guide for each version over any assistant.
  • Suppressing is not fixing. @, a narrower error_reporting or a handler that returns true and logs nothing only move the cost to the next major version, when deprecated features become candidates for removal.

A sensible order for fixing what you find: engine deprecations for your target PHP version first, because the host decides that date for you. Library deprecations come next, in the order you plan to upgrade those packages. Docblock-only warnings come last, since nothing breaks until you take that library’s next major.

FAQ

Does PHP show deprecation warnings in production?

Not by default. The shipped php.ini-production sets error_reporting = E_ALL & ~E_DEPRECATED and display_errors = Off, so deprecations are neither shown nor logged. A custom handler registered for E_DEPRECATED | E_USER_DEPRECATED still receives them, which is why the runtime logger above works on a production server.

Can I find PHP deprecations without upgrading PHP first?

Yes. PHPCompatibility checks against the versions you name in testVersion while running on your current PHP, and PHPStan’s deprecation rules do not depend on the PHP version at all. The lint pass does need the target binary, but the official php:<version>-cli Docker image gives you that without touching the server.

What is the difference between E_DEPRECATED and E_USER_DEPRECATED?

E_DEPRECATED comes from PHP itself when you use a language feature or internal function it plans to change or remove. E_USER_DEPRECATED comes from PHP code, usually a framework or library, calling trigger_error() to warn that one of its own functions is going away. Log both, and label them separately so you know whose changelog to read.

Should I silence deprecation notices with the @ operator?

No. It hides the message from your screen and from naive handlers, but the deprecated code still breaks on the version that removes it. Fix the call, or at minimum log it with a handler that ignores @ so it stays on your list.

Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *