Skip to content

fix: resolve PHP 8 fatal when active alongside plugins bundling psr/log v1#839

Open
superdav42 wants to merge 5 commits into
udx:mainfrom
superdav42:fix/psr3-monolog-jetpack-autoloader-compat
Open

fix: resolve PHP 8 fatal when active alongside plugins bundling psr/log v1#839
superdav42 wants to merge 5 commits into
udx:mainfrom
superdav42:fix/psr3-monolog-jetpack-autoloader-compat

Conversation

@superdav42
Copy link
Copy Markdown

Problem

When WP-Stateless is active alongside any plugin that bundles psr/log v1 via the Automattic Jetpack autoloader (e.g. WP Ultimo / Ultimate Multisite, Jetpack itself, WooCommerce), PHP 8 throws a fatal error the moment the Google SDK triggers Monolog to load:

PHP Fatal error: Declaration of Monolog\Logger::emergency(Stringable|string $message, array $context = []): void
must be compatible with Psr\Log\LoggerInterface::emergency($message, array $context = [])
in wp-stateless/lib/Google/vendor/monolog/monolog/src/Monolog/Logger.php on line 683

Root cause

lib/Google/vendor/autoload.php is included late — inside Bootstrap::__construct() (class-bootstrap.php line 427), well after WordPress has finished loading other plugins. By that time a competing plugin's Jetpack autoloader has already served Psr\Log\LoggerInterface from psr/log v1 (no parameter type hints). When Monolog 3 (which targets PSR-3 v3, Stringable|string) is first instantiated, PHP 8 finds the v1 interface already in its class cache and fatals because the implementation signature is narrower than what the interface declares.

This is not reproducible on PHP 7.x — Stringable is a PHP 8.0 type, so Monolog 3 cannot be loaded on PHP 7 regardless.

Fix

Adopt automattic/jetpack-autoloader and declare psr/log ^3 and monolog/monolog ^3 in the main composer.json.

Jetpack autoloader consolidates version registrations from all participating plugins and serves the highest version of each package to the first caller. With WP-Stateless declaring psr/log v3, the Jetpack autoloader selects v3 over any other plugin's v1 — regardless of which plugin loaded first. Monolog 3's Stringable|string implementation then matches the v3 interface exactly.

Verified with a PHP reproduction script against Ultimate Multisite (which uses Jetpack autoloader + psr/log v1.1.4):

Before — same error as reported:

Step 5: winning LoggerInterface::emergency param type = none (PSR-3 v1)
Step 6 FATAL: Declaration of Monolog\Logger::emergency ...

After:

Step 5: winning LoggerInterface::emergency param type = Stringable|string
Step 6: Monolog loaded OK - conflict resolved

Additional changes

Change Reason
firebase/php-jwt ^6.1.2^7.0 All v6.x versions carry security advisory PKSA-y2cr-5h3j-g3ys. lib/Google/composer.json already accepted ^6.0||^7.0.
coenjacobs/mozart 0.7.1brianhenryie/strauss ^0.19 mozart is archived/abandoned. Its transitive dep symfony/console v5.4.47 declares "conflict": {"psr/log": ">=3"}, blocking the psr/log v3 upgrade. Strauss is the maintained successor, supports the same scoping workflow, and uses symfony/console ^6+. The extra.mozart config key is renamed to extra.strauss.

Testing

composer install
# Confirm no psr/log v1 class loaded before Jetpack negotiates:
php -r "
  require 'vendor/autoload.php';
  \$logger = new \Monolog\Logger('test');
  echo get_class(\$logger) . PHP_EOL;
"

With a second plugin (e.g. Ultimate Multisite) present and its vendor/autoload.php required first, Monolog still loads cleanly because Jetpack autoloader picks psr/log v3 as the winner.

balexey88 and others added 4 commits January 16, 2026 15:55
When WP-Stateless is active alongside plugins that bundle psr/log v1
(e.g. WP Ultimo / Ultimate Multisite), PHP 8 fatals on Monolog load:

  Declaration of Monolog\Logger::emergency(Stringable|string $message,
  array $context = []): void must be compatible with
  Psr\Log\LoggerInterface::emergency($message, array $context = [])

Root cause: lib/Google/vendor/autoload.php is included late (Bootstrap
line 427), after a competing plugin's Jetpack autoloader has already
registered psr/log v1 (no type hints). When Monolog 3 (which targets
PSR-3 v3, Stringable|string) is first loaded, it finds the v1 interface
already in PHP's class cache, causing the fatal.

Fix: adopt automattic/jetpack-autoloader and declare psr/log ^3 and
monolog/monolog ^3 in the main composer.json. Jetpack autoloader
consolidates version registrations across all participating plugins at
load time and serves the highest version. With wp-stateless declaring
v3, psr/log v3 wins over any plugin's v1, providing the Stringable|string
interface that Monolog 3 expects.

Additional changes in this commit:
- firebase/php-jwt: ^6.1.2 -> ^7.0 (security advisory PKSA-y2cr-5h3j-g3ys
  on all v6.x; lib/Google/composer.json already allowed ^7.0)
- replace abandoned coenjacobs/mozart (conflicts: psr/log >=3 via
  symfony/console v5) with maintained brianhenryie/strauss ^0.19;
  update extra config key mozart -> strauss accordingly
Copilot AI review requested due to automatic review settings April 12, 2026 19:13
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates WP-Stateless dependencies and compatibility handling to prevent PHP 8 fatal errors caused by psr/log v1 vs v3 interface conflicts, and ships a 4.4.1 release note update.

Changes:

  • Adds Jetpack Autoloader + pins psr/log and monolog/monolog to v3 to resolve PSR-3 signature conflicts on PHP 8.
  • Replaces the internal “WooCommerce Extra Product Options” compatibility module with an external addon entry.
  • Improves JWT signing-key handling and bumps firebase/php-jwt to v7, updating changelogs/readme.

Reviewed changes

Copilot reviewed 8 out of 2111 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
static/data/addons.php Registers the new external addon replacing the internal compatibility module.
readme.txt Bumps stable tag and adds 4.4.1 changelog notes.
lib/classes/compatibility/woo-extra-product-options.php Removes the internal compatibility implementation.
lib/classes/class-utility.php Adds secure JWT signing key retrieval/generation and uses it for encode/decode.
lib/classes/class-module.php Stops instantiating the removed internal compatibility module.
composer.json Adds Jetpack autoloader and bumps dependencies; switches Mozart config to Strauss.
changes.md Adds 4.4.1 release notes.
changelog.txt Adds 4.4.1 release notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread static/data/addons.php
Comment on lines +170 to +178
'woo-extra-product-options' => [
'title' => 'WooCommerce Extra Product Options Addon',
'plugin_files' => ['woocommerce-tm-extra-product-options/tm-woo-extra-product-options.php'],
'addon_file' => 'wp-stateless-woocommerce-extra-product-options-addon/wp-stateless-woo-extra-product-options-addon.php.php',
'icon' => 'https://ps.w.org/woocommerce/assets/icon.svg',
'repo' => 'udx/wp-stateless-woo-extra-product-options-addon',
'wp' => 'https://wordpress.org/plugins/wp-stateless-woocommerce-extra-product-options-addon/',
'hubspot_id' => '151478251017',
],
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The addon_file value ends with .php.php, which will prevent WordPress from locating/including the addon bootstrap file. Update it to the correct single .php filename (matching the addon’s main plugin file path).

Copilot uses AI. Check for mistakes.
Comment thread composer.json
@@ -32,10 +32,13 @@
"php": ">=7.4",
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package now requires dependencies (notably monolog/monolog v3) that target PHP 8.1+, and the plugin readme already states PHP 8.1 is required. Update the Composer platform requirement (\"php\") to >=8.1 to accurately reflect the supported runtime and prevent misleading installs/resolution attempts on older PHP versions.

Suggested change
"php": ">=7.4",
"php": ">=8.1",

Copilot uses AI. Check for mistakes.
Comment thread composer.json Outdated
Comment on lines +39 to +41
"automattic/jetpack-autoloader": "^3.0 || ^4.0 || ^5.0",
"psr/log": "^3.0",
"monolog/monolog": "^3.0"
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package now requires dependencies (notably monolog/monolog v3) that target PHP 8.1+, and the plugin readme already states PHP 8.1 is required. Update the Composer platform requirement (\"php\") to >=8.1 to accurately reflect the supported runtime and prevent misleading installs/resolution attempts on older PHP versions.

Copilot uses AI. Check for mistakes.
Comment on lines +715 to +739
* Get a secure JWT signing key
* Priority: AUTH_SALT (if valid length) > Plugin-specific stored key > Generated key
*
* @return string A key suitable for HS256 (minimum 32 bytes)
*/
public static function get_jwt_signing_key() {
// Minimum key length for HS256 (256 bits = 32 bytes)
$min_key_length = 32;

// Try AUTH_SALT first if it's long enough
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && strlen(AUTH_SALT) >= $min_key_length) {
return AUTH_SALT;
}

// Try to get stored plugin-specific key
$stored_key = get_option('wp_stateless_jwt_key');

if ($stored_key && strlen($stored_key) >= $min_key_length) {
return $stored_key;
}

// Generate a new secure key
$new_key = self::generate_secure_key($min_key_length);
update_option('wp_stateless_jwt_key', $new_key, false);

Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docblocks and length semantics are inconsistent: get_jwt_signing_key() promises a minimum 32 bytes key, but generate_secure_key() returns a Base64-encoded string (where strlen() is characters, not bytes), and the fallback uses a different sizing scheme ($length * 2). Make the contract explicit (e.g., “32 bytes of entropy” vs “32+ characters”), and align the validation logic accordingly (either validate decoded byte length for Base64, or generate/store a fixed-length hex/raw string and validate that exact format). Also update the outdated comment “PHP 7+” given the plugin’s PHP requirement.

Suggested change
* Get a secure JWT signing key
* Priority: AUTH_SALT (if valid length) > Plugin-specific stored key > Generated key
*
* @return string A key suitable for HS256 (minimum 32 bytes)
*/
public static function get_jwt_signing_key() {
// Minimum key length for HS256 (256 bits = 32 bytes)
$min_key_length = 32;
// Try AUTH_SALT first if it's long enough
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && strlen(AUTH_SALT) >= $min_key_length) {
return AUTH_SALT;
}
// Try to get stored plugin-specific key
$stored_key = get_option('wp_stateless_jwt_key');
if ($stored_key && strlen($stored_key) >= $min_key_length) {
return $stored_key;
}
// Generate a new secure key
$new_key = self::generate_secure_key($min_key_length);
update_option('wp_stateless_jwt_key', $new_key, false);
* Validate that a JWT signing key provides at least the required bytes of key material.
*
* Accepts either:
* - a raw string whose byte length is at least $min_key_bytes, or
* - a Base64-encoded string whose strictly decoded byte length is at least $min_key_bytes.
*
* @param string $key Raw or Base64-encoded key.
* @param int $min_key_bytes Minimum required bytes of key material.
*
* @return bool
*/
private static function is_valid_jwt_signing_key($key, $min_key_bytes = 32) {
if (!is_string($key) || $key === '') {
return false;
}
if (strlen($key) >= $min_key_bytes) {
return true;
}
$decoded_key = base64_decode($key, true);
return $decoded_key !== false && strlen($decoded_key) >= $min_key_bytes;
}
/**
* Generate a JWT signing key with at least the required bytes of key material.
*
* This preserves the existing generate_secure_key() behavior when it returns a valid key,
* and falls back to a 64-character hex string representing 32 bytes of entropy.
*
* @param int $min_key_bytes Minimum required bytes of key material.
*
* @return string
* @throws \Exception
*/
private static function generate_jwt_signing_key($min_key_bytes = 32) {
$generated_key = self::generate_secure_key($min_key_bytes);
if (self::is_valid_jwt_signing_key($generated_key, $min_key_bytes)) {
return $generated_key;
}
return bin2hex(random_bytes($min_key_bytes));
}
/**
* Get a secure JWT signing key.
* Priority: AUTH_SALT (if it provides sufficient key material) > plugin-specific stored key > generated key.
*
* @return string A key suitable for HS256 with at least 32 bytes of key material.
* @throws \Exception
*/
public static function get_jwt_signing_key() {
$min_key_bytes = 32;
// Use AUTH_SALT when it provides enough key material.
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && self::is_valid_jwt_signing_key(AUTH_SALT, $min_key_bytes)) {
return AUTH_SALT;
}
// Use the stored plugin-specific key when it provides enough key material.
$stored_key = get_option('wp_stateless_jwt_key');
if ($stored_key && self::is_valid_jwt_signing_key($stored_key, $min_key_bytes)) {
return $stored_key;
}
// Generate and persist a new key that satisfies the byte-length requirement.
$new_key = self::generate_jwt_signing_key($min_key_bytes);
update_option('wp_stateless_jwt_key', $new_key, false);

Copilot uses AI. Check for mistakes.
Comment on lines +715 to +756
* Get a secure JWT signing key
* Priority: AUTH_SALT (if valid length) > Plugin-specific stored key > Generated key
*
* @return string A key suitable for HS256 (minimum 32 bytes)
*/
public static function get_jwt_signing_key() {
// Minimum key length for HS256 (256 bits = 32 bytes)
$min_key_length = 32;

// Try AUTH_SALT first if it's long enough
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && strlen(AUTH_SALT) >= $min_key_length) {
return AUTH_SALT;
}

// Try to get stored plugin-specific key
$stored_key = get_option('wp_stateless_jwt_key');

if ($stored_key && strlen($stored_key) >= $min_key_length) {
return $stored_key;
}

// Generate a new secure key
$new_key = self::generate_secure_key($min_key_length);
update_option('wp_stateless_jwt_key', $new_key, false);

return $new_key;
}

/**
* Generate a cryptographically secure random key
*
* @param int $length Key length in bytes
* @return string Base64-encoded key
*/
private static function generate_secure_key($length = 32) {
try {
// Use random_bytes for PHP 7+
$random_bytes = random_bytes($length);
return base64_encode($random_bytes);
} catch (\Exception $e) {
// Fallback: use wp_generate_password
return wp_generate_password($length * 2, true, true);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docblocks and length semantics are inconsistent: get_jwt_signing_key() promises a minimum 32 bytes key, but generate_secure_key() returns a Base64-encoded string (where strlen() is characters, not bytes), and the fallback uses a different sizing scheme ($length * 2). Make the contract explicit (e.g., “32 bytes of entropy” vs “32+ characters”), and align the validation logic accordingly (either validate decoded byte length for Base64, or generate/store a fixed-length hex/raw string and validate that exact format). Also update the outdated comment “PHP 7+” given the plugin’s PHP requirement.

Suggested change
* Get a secure JWT signing key
* Priority: AUTH_SALT (if valid length) > Plugin-specific stored key > Generated key
*
* @return string A key suitable for HS256 (minimum 32 bytes)
*/
public static function get_jwt_signing_key() {
// Minimum key length for HS256 (256 bits = 32 bytes)
$min_key_length = 32;
// Try AUTH_SALT first if it's long enough
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && strlen(AUTH_SALT) >= $min_key_length) {
return AUTH_SALT;
}
// Try to get stored plugin-specific key
$stored_key = get_option('wp_stateless_jwt_key');
if ($stored_key && strlen($stored_key) >= $min_key_length) {
return $stored_key;
}
// Generate a new secure key
$new_key = self::generate_secure_key($min_key_length);
update_option('wp_stateless_jwt_key', $new_key, false);
return $new_key;
}
/**
* Generate a cryptographically secure random key
*
* @param int $length Key length in bytes
* @return string Base64-encoded key
*/
private static function generate_secure_key($length = 32) {
try {
// Use random_bytes for PHP 7+
$random_bytes = random_bytes($length);
return base64_encode($random_bytes);
} catch (\Exception $e) {
// Fallback: use wp_generate_password
return wp_generate_password($length * 2, true, true);
* Get a secure JWT signing key.
* Priority: AUTH_SALT (if it provides enough key material) > Plugin-specific stored key > Generated key.
*
* A valid key must provide at least 32 bytes of key material for HS256. Raw strings are validated
* by byte length, and Base64-encoded strings are also accepted when their decoded value is at least
* 32 bytes long.
*
* @return string A key suitable for HS256 with at least 32 bytes of key material.
*/
public static function get_jwt_signing_key() {
// Minimum key length for HS256 (256 bits = 32 bytes of key material).
$min_key_length = 32;
// Try AUTH_SALT first if it provides enough key material.
if (defined('AUTH_SALT') && !empty(AUTH_SALT) && self::is_valid_jwt_signing_key(AUTH_SALT, $min_key_length)) {
return AUTH_SALT;
}
// Try to get a stored plugin-specific key.
$stored_key = get_option('wp_stateless_jwt_key');
if ($stored_key && self::is_valid_jwt_signing_key($stored_key, $min_key_length)) {
return $stored_key;
}
// Generate and persist a new secure key.
$new_key = self::generate_secure_key($min_key_length);
update_option('wp_stateless_jwt_key', $new_key, false);
return $new_key;
}
/**
* Determine whether a JWT signing key provides the required minimum key material.
*
* Raw strings are validated by byte length. Base64-encoded strings are also accepted if their
* decoded value is valid and contains at least the required number of bytes.
*
* @param string $key Candidate key.
* @param int $min_key_bytes Minimum required bytes of key material.
* @return bool
*/
private static function is_valid_jwt_signing_key($key, $min_key_bytes = 32) {
if (!is_string($key) || $key === '') {
return false;
}
if (strlen($key) >= $min_key_bytes) {
return true;
}
$decoded_key = base64_decode($key, true);
if ($decoded_key === false) {
return false;
}
return strlen($decoded_key) >= $min_key_bytes;
}
/**
* Generate a cryptographically secure random key.
*
* When a CSPRNG is available, this returns a Base64-encoded string containing exactly
* $length bytes of random data. If a CSPRNG is unavailable, it falls back to a raw
* password string of $length characters so the minimum key-length contract remains consistent.
*
* @param int $length Number of bytes of key material required.
* @return string Generated key string.
*/
private static function generate_secure_key($length = 32) {
try {
$random_bytes = random_bytes($length);
return base64_encode($random_bytes);
} catch (\Exception $e) {
// Fallback: use WordPress password generation with the same minimum length semantics.
return wp_generate_password($length, true, true);

Copilot uses AI. Check for mistakes.
…O conflict

psr/simple-cache v3 defines CacheInterface with native PHP 8 types
(e.g. get(string $key, mixed $default = null): mixed). WP Ultimo's
WordPress_Simple_Cache implements the interface with untyped v1-style
signatures, causing a fatal error when both plugins are active and the
Jetpack autoloader loads the v3 interface globally.

Pin psr/simple-cache to ^1.0 (no native types). All upstream deps
(json-mapper, symfony/cache) accept v1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants