fix: resolve PHP 8 fatal when active alongside plugins bundling psr/log v1#839
fix: resolve PHP 8 fatal when active alongside plugins bundling psr/log v1#839superdav42 wants to merge 5 commits into
Conversation
Release 4.4.1
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
There was a problem hiding this comment.
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/logandmonolog/monologto 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-jwtto 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.
| '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', | ||
| ], |
There was a problem hiding this comment.
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).
| @@ -32,10 +32,13 @@ | |||
| "php": ">=7.4", | |||
There was a problem hiding this comment.
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.
| "php": ">=7.4", | |
| "php": ">=8.1", |
| "automattic/jetpack-autoloader": "^3.0 || ^4.0 || ^5.0", | ||
| "psr/log": "^3.0", | ||
| "monolog/monolog": "^3.0" |
There was a problem hiding this comment.
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.
| * 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); | ||
|
|
There was a problem hiding this comment.
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.
| * 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); |
| * 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); |
There was a problem hiding this comment.
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.
| * 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); |
…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.
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:
Root cause
lib/Google/vendor/autoload.phpis included late — insideBootstrap::__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 servedPsr\Log\LoggerInterfacefrom 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 —
Stringableis a PHP 8.0 type, so Monolog 3 cannot be loaded on PHP 7 regardless.Fix
Adopt
automattic/jetpack-autoloaderand declarepsr/log ^3andmonolog/monolog ^3in the maincomposer.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|stringimplementation 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:
After:
Additional changes
firebase/php-jwt ^6.1.2→^7.0PKSA-y2cr-5h3j-g3ys.lib/Google/composer.jsonalready accepted^6.0||^7.0.coenjacobs/mozart 0.7.1→brianhenryie/strauss ^0.19symfony/console v5.4.47declares"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+. Theextra.mozartconfig key is renamed toextra.strauss.Testing
With a second plugin (e.g. Ultimate Multisite) present and its
vendor/autoload.phprequired first, Monolog still loads cleanly because Jetpack autoloader picks psr/log v3 as the winner.