Skip to content

Why environment variables don’t suppress WP-CLI PHP Deprecated warnings — the phar + shebang path and a three-part structural fix

A previous post covered how to absorb PHP 8.2 Deprecated warnings from WP-CLI using a three-layer defense. The approach — prepending WP_CLI_PHP_ARGS to set error_reporting — works in many environments. But a case came up where Deprecated warnings wouldn’t disappear despite the same configuration. Tracing the cause revealed a structural reason why the environment variable never arrived. This post records that root cause and the three-part fix added in v1.6.8.

Why environment variables don’t arrive — the phar + shebang execution path

An agency reported that on Xserver, plugin list retrieval was failing across multiple sites (referred to here as “site A / site B”) with a large volume of Deprecated messages. We reproduced the same behavior on our own Xserver setup (PHP 8.2.30, WP-CLI 2.7.1) and traced the execution path.

Xserver’s /usr/bin/wp is a phar binary. Inside, it starts with a #!/usr/bin/env php shebang, so the actual startup sequence looks like this:

shell → /usr/bin/wp (shebang: #!/usr/bin/env php)
                  ↓
        env locates php and starts it
                  ↓
        php loads the phar → WP-CLI runs

In this path, WP_CLI_PHP_ARGS is never read as a PHP startup option. WP_CLI_PHP_ARGS is supposed to let WP-CLI pass a -d flag to PHP, but when PHP itself is launched via shebang, control never reaches the point where WP-CLI can inject that flag into PHP’s invocation.

# doesn’t work — /usr/bin/wp on Xserver is a shebang-launched phar
WP_CLI_PHP_ARGS="-d error_reporting='E_ALL & ~E_DEPRECATED'" wp plugin list --format=json

# works — -d goes directly to the php binary
php -d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED' /tmp/wp-cli-2.7.1.phar plugin list --format=json

We verified this against our production setup: with the first form, 407 Deprecated lines remained; with the second, 0.

Pillar A — detecting the php-direct path and injecting -d

The fix: inspect wp_cli_path for whether it’s a php-direct invocation, and if so, inject -d error_reporting immediately after the PHP binary.

inject_php_d_flag(wp_cli_path) in core/wpcli_json.py splits the path and checks the leading binary name.

  • If the binary is php, php8.2, php82, or any php* variant — inject -d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED' right after it.
  • If the path is a bare wp or a direct .phar invocation — leave it as-is.
def inject_php_d_flag(wp_cli_path: str) -> str:
    parts = shlex.split(wp_cli_path)
    if not parts:
        return wp_cli_path
    binary = os.path.basename(parts[0]).lower().rstrip(".exe")
    if not (binary == "php" or binary.startswith("php")):
        return wp_cli_path  # bare wp / phar — leave unchanged
    flag = "-d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED'"
    return shlex.join([parts[0], flag] + parts[1:])

Using os.path.basename ensures this works with both / and \ path separators — Linux on the server, Windows on the client.

Pillar B — locating the JSON range within noisy output

Even after suppressing Deprecated warnings, other noise (notices, warnings) can still appear in stdout. To handle that, we added a helper that extracts only the JSON portion from stdout rather than attempting to parse the entire string.

extract_json_from_wpcli_output(stdout) locates the first [ or { and the last ] or } in the output, and passes only that range to json.loads.

def extract_json_from_wpcli_output(stdout: str) -> Any:
    start = min(
        (stdout.find(c) for c in ("[", "{") if c in stdout),
        default=-1,
    )
    end = max(
        (stdout.rfind(c) for c in ("]", "}") if c in stdout),
        default=-1,
    )
    if start == -1 or end == -1 or start > end:
        return None
    return json.loads(stdout[start : end + 1])

PHP fatal errors and parse errors typically contain no JSON brackets, or contain them in positions that yield invalid JSON — so json.loads raises and the helper returns None. False positives stay low.

Pillar C — hardening nine json.loads call sites

The existing code had nine places calling json.loads(stdout) directly. These were unified to return a (parsed, raw) tuple, so failed parses still have the original stdout available for error handling and logging.

def parse_wpcli_json(stdout: str) -> tuple[Any, str]:
    try:
        return extract_json_from_wpcli_output(stdout), stdout
    except (json.JSONDecodeError, TypeError):
        return None, stdout

Keeping the raw output alongside the parse result means that when JSON decoding fails, there’s still something useful available — the actual stdout — without reconstructing it.

46 AST tests to prevent regression

Alongside the three pillars, we added tests/test_wpcli_deprecation_noise.py with 46 tests covering:

  • php detection boundary testsphp, php8.2, php82 trigger injection; wp, .phar do not
  • -d injection position tests — the modified command string has the flag in the correct position
  • 7+ JSON locate cases — Deprecated noise mixed in, Fatal error only, clean JSON, empty string, and others
  • Structural static verification via AST — confirming that the existing json.loads call sites have actually been migrated to the tuple-return pattern

The V40 tests confirmed that the defense patterns ran correctly. These go one step further and use AST-based static analysis to verify that the code itself retains the intended structure — catching regressions that runtime tests alone would miss. Total test count went from 942 to 993.

Wrap-up

Environment-variable-based Deprecated suppression works when PHP is invoked directly, but doesn’t reach a shebang-launched phar like /usr/bin/wp on Xserver. The v1.6.8 fix addresses this in three layers:

  • Pillar A: parse wp_cli_path and inject -d directly when it’s a php-direct invocation
  • Pillar B: locate the JSON range in stdout rather than parsing the whole string
  • Pillar C: unify json.loads call sites to return (parsed, raw) tuples

When an environment variable “doesn’t work,” the most productive first question is whether the path it’s supposed to reach even sees it. That structural diagnosis — rather than trying variations of the same configuration — is what led to this fix. For the original Deprecated noise and the v1.6.6 three-layer defense, the earlier post on WP-CLI PHP Deprecated noise covers the symptom side of the same problem.