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 anyphp*variant — inject-d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED'right after it. - If the path is a bare
wpor a direct.pharinvocation — 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 tests —
php,php8.2,php82trigger injection;wp,.phardo 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.loadscall 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_pathand inject-ddirectly 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.loadscall 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.