Skip to content

Quieting PHP 8.2+ deprecated noise from older WP-CLI — three layers to keep JSON parse clean

Our multi-site maintenance tool fires wp plugin list --format=json against the sites it manages. One day, against a specific shared host (Xserver in Japan), this call started failing — and the failure mode was unusually subtle.

Both the SSH connection test and the WP-CLI path test (wp --version) came back green. Users saw “all diagnostics pass, but the actual operation fails,” a frustrating asymmetry. Tracing it back, the root cause was PHP Deprecated warnings emitted by older WP-CLI (2.x) under PHP 8.2+ leaking into the JSON output.

This post walks through the three-layer defense we used to structurally absorb the noise without losing real failures.

What was happening — Deprecated warnings on stdout

The raw output on a problem host looked like this:

PHP Deprecated:  Creation of dynamic property
WP_CLI\Dispatcher\CompositeCommand::$longdesc is deprecated
in phar:///usr/bin/wp/vendor/wp-cli/wp-cli/php/...
[
  {"name":"akismet","status":"active","update":"none", ...},
  ...
]

Since PHP 8.2, assigning to a dynamic property on a class without #[\AllowDynamicProperties] emits a Deprecated warning. Xserver’s /usr/bin/wp (an older WP-CLI 2.x) leans on dynamic properties internally, so running it on PHP 8.2+ produces a steady stream of those warnings.

Note: PHP 8.2’s dynamic-property deprecation is a healthy direction for the language. But during the transition, you get many libraries that “warn but still work” — WP-CLI was one of them.

The actual problem is the host’s php.ini: depending on display_errors, those warnings end up on stdout instead of stderr. Calling wp plugin list --format=json returns stdout containing both the warnings and the JSON, and json_decode() fails on the mixed input.

Why diagnostics stayed green but operations failed

The frustrating asymmetry came from how each test was checking the output:

  • SSH connection test: runs echo ok — passes as long as ok appears somewhere in stdout, extra lines are fine
  • WP-CLI path test: runs wp --version — passes as long as a version string is found
  • Real operation: runs wp plugin list --format=jsonthe JSON parse step is the only place the noise actually matters

To the user it looks like “all my tests are green, but the real call fails.” If your diagnostics only check exit code and “did the expected substring appear,” anything that surfaces only at the structured-output stage slips through silently. This is the same shape as the trap we hit with SSH commands failing on csh login shells — single commands pass while structured workflows break.

Three layers of defense

You can try to suppress the warnings entirely, but you can’t fully predict every host’s php.ini configuration — so we built multiple independent layers that each catch a different leakage path.

Layer 1 — WP_CLI_PHP_ARGS to silence warnings at the source

WP-CLI exposes an environment variable WP_CLI_PHP_ARGS that gets forwarded to the underlying PHP invocation. We set it to mask Deprecated entries via error_reporting:

_WP_CLI_PHP_QUIET_ARGS = (
    "-d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED'"
)

def _wp_with_quiet_php(wp_cli_path: str) -> str:
    """Wrap a WP-CLI call with WP_CLI_PHP_ARGS to suppress Deprecated warnings."""
    return (
        f"WP_CLI_PHP_ARGS={shlex.quote(_WP_CLI_PHP_QUIET_ARGS)} "
        f"{wp_cli_path}"
    )

shlex.quote keeps the value safely escaped for the shell, and the function plays nicely with our existing per-site WP-CLI path override feature. Parse and Fatal errors still surface — only Deprecated/Notice-level noise gets quieted.

Layer 2 — strip noise lines before JSON parse

Layer 1 cleans up most environments, but if the host overrides error_reporting again at runtime (php.ini -> ini_set() chain), warnings can still slip through. As defense in depth, we strip recognized noise lines from stdout before parsing.

_PHP_NOISE_LINE_RE = re.compile(
    r'^\s*PHP\s+(Deprecated|Warning|Notice|Strict Standards):.*$',
    re.MULTILINE | re.IGNORECASE
)

def _strip_php_noise(text: str) -> str:
    """Remove PHP Deprecated/Warning/Notice/Strict Standards lines from stdout.
    Parse error / Fatal error are NOT stripped — those are real failures
    the user should see."""
    return _PHP_NOISE_LINE_RE.sub('', text)

The deliberate omission of Parse error and Fatal error matters. Those mean the operation actually broke, and the user needs to see them. The regex enumeration of four noise categories draws the line cleanly between “annoyance” and “actual failure.”

Layer 3 — try JSON even when exit code is nonzero

Layers 1 and 2 catch most cases, but a small number of hosts return exit code 1 just because of warnings while leaving valid JSON in stdout. Fabric (paramiko) reports res.ok = False, but the parseable data is right there.

stdout_clean = _strip_php_noise(res.stdout or '').strip()
plugins = None
if stdout_clean:
    try:
        plugins = json.loads(stdout_clean)
    except json.JSONDecodeError:
        plugins = None

if plugins is None:
    # Only here do we conclude "no JSON" — fall to error
    if not res.ok:
        return error_response(res.stderr or res.stdout)

The trick is try JSON before trusting the exit code. If stdout contains a valid structure, treat the call as successful even with a nonzero exit.

Spread the fix across all three APIs at once

Same principle as V12 (the csh portability bug): when you find this kind of issue, grep for the same pattern across the codebase and fix everywhere at once. The plugin-list fetch lived in three call sites:

  • /api/fetch_plugins — the cross-site plugin dashboard
  • /api/site_plugins — the per-site plugin list modal
  • _do_fetch_pending_plugins_for_site — the maintenance-time pending-update scan

All three were rewritten to use _wp_with_quiet_php + _strip_php_noise + JSON-first parsing. Fixing only one would have left the same regression alive on a different path.

For regression defense, tests/test_wp_cli_php_noise.py ships with 18 cases (noise-line removal, Parse error preservation, env-var formatting, shlex quoting, compatibility with per-site WP-CLI path override, and presence checks for all three APIs). If anyone later adds a fourth API that calls json_decode directly on raw c.run output, the CI fails.

Closing — “warning-level differences × structured output”

Three principles worth keeping from this round:

  1. Diagnostics green / production red is structurally easy to produce. Most tests check “did the command run and produce expected substrings” — structured-output paths see the noise that simple-string paths don’t. Add one structured-output parse step to the diagnostics to catch this class of bug early
  2. Layer noise suppression in independent steps. Source suppression (error_reporting) / line filtering / exit-code bypass are independent defenses. Stack them, and a hosting quirk that defeats one layer still gets caught by another. You can’t fully predict host configurations — layering is the answer
  3. Distinguish noise from real failures by regex. Deprecated / Warning / Notice / Strict Standards are safe to strip; Parse error / Fatal error must always pass through. “Just hide all the warnings” hides the real failures too — enumeration with a tight regex draws the right line

When you’re invoking environment-sensitive CLI tools like PHP / WP-CLI from a maintenance tool, similar bugs are probably sitting under the surface. The “error_reporting suppression + noise-line stripping + JSON-first parse” template is a reusable pattern worth keeping in the toolbox.