Repository navigation
[2.1]: Full PHP 8.5 compatibility audit and fixes #9179
Description
Basic Information
The release-2.1 branch has multiple PHP 8.5 incompatibilities that need to be addressed. PHP 8.5 promotes several deprecations from PHP 8.1–8.4 to errors/removals, and the current codebase — originally targeting PHP 7.0 — is affected in numerous places. The CI workflow (.github/workflows/php.yml) only lints syntax up to PHP 8.4 and does not run any runtime tests, so these issues are not currently caught.
This issue consolidates all known and discoverable PHP 8.5 incompatibilities into a single tracking issue with exact code references.
1. trigger_error() with E_USER_ERROR (deprecated PHP 8.4, error in 8.5)
PHP 8.4 deprecated passing E_USER_ERROR to trigger_error(). In PHP 8.5, this will throw a ValueError. SMF's database error backtrace function passes E_USER_ERROR through from callers and also directly invokes trigger_error() with it.
Related issue: #9141 (open, in 2.1.8 milestone — but only covers the DB layer)
1a. Sources/Subs-Db-mysql.php — smf_db_error_backtrace()
Lines 876 to 916 in 7438c2b
| function smf_db_error_backtrace($error_message, $log_message = '', $error_type = false, $file = null, $line = null) | |
| { | |
| if (empty($log_message)) | |
| $log_message = $error_message; | |
| foreach (debug_backtrace() as $step) | |
| { | |
| // Found it? | |
| if (strpos($step['function'], 'query') === false && !in_array(substr($step['function'], 0, 7), array('smf_db_', 'preg_re', 'db_erro', 'call_us')) && strpos($step['function'], '__') !== 0) | |
| { | |
| $log_message .= '<br>Function: ' . $step['function']; | |
| break; | |
| } | |
| if (isset($step['line'])) | |
| { | |
| $file = $step['file']; | |
| $line = $step['line']; | |
| } | |
| } | |
| // A special case - we want the file and line numbers for debugging. | |
| if ($error_type == 'return') | |
| return array($file, $line); | |
| // Is always a critical error. | |
| if (function_exists('log_error')) | |
| log_error($log_message, 'critical', $file, $line); | |
| if (function_exists('fatal_error')) | |
| { | |
| fatal_error($error_message, false); | |
| // Cannot continue... | |
| exit; | |
| } | |
| elseif ($error_type) | |
| trigger_error($error_message . ($line !== null ? '<em>(' . basename($file) . '-' . $line . ')</em>' : ''), $error_type); | |
| else | |
| trigger_error($error_message . ($line !== null ? '<em>(' . basename($file) . '-' . $line . ')</em>' : '')); | |
| } |
The function accepts $error_type and passes it directly to trigger_error() on lines 913–915. Every caller that passes E_USER_ERROR triggers the deprecation:
- Line 195 —
smf_db_error_backtrace('Invalid value inserted...', '', E_USER_ERROR, ...) - Line 209 — Integer type check failure
- Line 222 — Empty array_int
- Line 227 — Non-integer in array_int
- Line 235 — Non-array for array_int
- Line 243 — Empty array_string
- Line 251 — Non-array for array_string
- Line 258 — Invalid date
- Line 265 — Invalid time
- Line 274 ��� Invalid datetime
- Line 279 — Invalid float
- Line 296 — Invalid IPv4/IPv6
- Line 304 — Empty array_inet
- Line 311 — Invalid inet value
- Line 318 — Non-array for array_inet
Lines 195 to 318 in 7438c2b
| smf_db_error_backtrace('Invalid value inserted or no type specified.', '', E_USER_ERROR, __FILE__, __LINE__); | |
| if ($matches[1] === 'literal') | |
| return '\'' . mysqli_real_escape_string($connection, $matches[2]) . '\''; | |
| if (!isset($values[$matches[2]])) | |
| smf_db_error_backtrace('The database value you\'re trying to insert does not exist: ' . (isset($smcFunc['htmlspecialchars']) ? $smcFunc['htmlspecialchars']($matches[2]) : htmlspecialchars($matches[2])), '', E_USER_ERROR, __FILE__, __LINE__); | |
| $replacement = $values[$matches[2]]; | |
| switch ($matches[1]) | |
| { | |
| case 'int': | |
| if (!is_numeric($replacement) || (string) $replacement !== (string) (int) $replacement) | |
| smf_db_error_backtrace('Wrong value type sent to the database. Integer expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| return (string) (int) $replacement; | |
| break; | |
| case 'string': | |
| case 'text': | |
| return sprintf('\'%1$s\'', mysqli_real_escape_string($connection, isset($smcFunc['fix_utf8mb4']) ? $smcFunc['fix_utf8mb4']($replacement) : $replacement)); | |
| break; | |
| case 'array_int': | |
| if (is_array($replacement)) | |
| { | |
| if (empty($replacement)) | |
| smf_db_error_backtrace('Database error, given array of integer values is empty. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| foreach ($replacement as $key => $value) | |
| { | |
| if (!is_numeric($value) || (string) $value !== (string) (int) $value) | |
| smf_db_error_backtrace('Wrong value type sent to the database. Array of integers expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| $replacement[$key] = (string) (int) $value; | |
| } | |
| return implode(', ', $replacement); | |
| } | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Array of integers expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| break; | |
| case 'array_string': | |
| if (is_array($replacement)) | |
| { | |
| if (empty($replacement)) | |
| smf_db_error_backtrace('Database error, given array of string values is empty. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| foreach ($replacement as $key => $value) | |
| $replacement[$key] = sprintf('\'%1$s\'', mysqli_real_escape_string($connection, isset($smcFunc['fix_utf8mb4']) ? $smcFunc['fix_utf8mb4']($value) : $value)); | |
| return implode(', ', $replacement); | |
| } | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Array of strings expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| break; | |
| case 'date': | |
| if (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d)$~', $replacement, $date_matches) === 1) | |
| return sprintf('\'%04d-%02d-%02d\'', $date_matches[1], $date_matches[2], $date_matches[3]); | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Date expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| break; | |
| case 'time': | |
| if (preg_match('~^([0-1]?\d|2[0-3]):([0-5]\d):([0-5]\d)$~', $replacement, $time_matches) === 1) | |
| return sprintf('\'%02d:%02d:%02d\'', $time_matches[1], $time_matches[2], $time_matches[3]); | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Time expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| break; | |
| case 'datetime': | |
| if (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d) ([0-1]?\d|2[0-3]):([0-5]\d):([0-5]\d)$~', $replacement, $datetime_matches) === 1) | |
| return 'str_to_date(' . | |
| sprintf('\'%04d-%02d-%02d %02d:%02d:%02d\'', $datetime_matches[1], $datetime_matches[2], $datetime_matches[3], $datetime_matches[4], $datetime_matches[5], $datetime_matches[6]) . | |
| ',\'%Y-%m-%d %h:%i:%s\')'; | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Datetime expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| break; | |
| case 'float': | |
| if (!is_numeric($replacement)) | |
| smf_db_error_backtrace('Wrong value type sent to the database. Floating point number expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| return (string) (float) $replacement; | |
| break; | |
| case 'identifier': | |
| // Backticks inside identifiers are supported as of MySQL 4.1. We don't need them for SMF. | |
| return '`' . implode('`.`', array_filter(explode('.', strtr($replacement, array('`' => ''))), 'strlen')) . '`'; | |
| break; | |
| case 'raw': | |
| return $replacement; | |
| break; | |
| case 'inet': | |
| if ($replacement == 'null' || $replacement == '') | |
| return 'null'; | |
| if (!isValidIP($replacement)) | |
| smf_db_error_backtrace('Wrong value type sent to the database. IPv4 or IPv6 expected.(' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| //we don't use the native support of mysql > 5.6.2 | |
| return sprintf('unhex(\'%1$s\')', bin2hex(inet_pton($replacement))); | |
| case 'array_inet': | |
| if (is_array($replacement)) | |
| { | |
| if (empty($replacement)) | |
| smf_db_error_backtrace('Database error, given array of IPv4 or IPv6 values is empty. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| foreach ($replacement as $key => $value) | |
| { | |
| if ($replacement == 'null' || $replacement == '') | |
| $replacement[$key] = 'null'; | |
| if (!isValidIP($value)) | |
| smf_db_error_backtrace('Wrong value type sent to the database. IPv4 or IPv6 expected.(' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); | |
| $replacement[$key] = sprintf('unhex(\'%1$s\')', bin2hex(inet_pton($value))); | |
| } | |
| return implode(', ', $replacement); | |
| } | |
| else | |
| smf_db_error_backtrace('Wrong value type sent to the database. Array of IPv4 or IPv6 expected. (' . $matches[2] . ')', '', E_USER_ERROR, __FILE__, __LINE__); |
1b. Sources/Subs-Compat.php — disabled function polyfill uses eval() + trigger_error()
Lines 542 to 561 in 7438c2b
| if (version_compare(PHP_VERSION, '8.0.0', '>=')) | |
| { | |
| /* | |
| * This array contains function names that meet the following conditions: | |
| * | |
| * 1. SMF assumes they are defined, even if disabled. Note that prior to | |
| * PHP 8, this was always true for internal functions. | |
| * | |
| * 2. Some hosts are known to disable them. | |
| * | |
| * 3. SMF can get by without them (as opposed to missing functions that | |
| * really SHOULD cause execution to halt). | |
| */ | |
| foreach (array('set_time_limit') as $func) | |
| { | |
| if (!function_exists($func)) | |
| eval('function ' . $func . '() { trigger_error("' . $func . '() has been disabled for security reasons", E_USER_WARNING); }'); | |
| } | |
| unset($func); | |
| } |
Line 558 creates a polyfill via eval() that calls trigger_error() with E_USER_WARNING — this one is fine, but the overall pattern of using trigger_error() for fatal errors throughout the codebase is the concern.
Fix: Replace all trigger_error($msg, E_USER_ERROR) calls with a proper exception or fatal_error() call. The smf_db_error_backtrace() function already has a fatal_error() path — the trigger_error() fallback on line 913 needs to use E_USER_WARNING or throw an exception instead of E_USER_ERROR.
2. mysqli_ping() deprecated (PHP 8.4, removal expected in future)
mysqli_ping() was deprecated in PHP 8.4 and emits a deprecation warning.
Line 61 in 7438c2b
| 'db_ping' => 'mysqli_ping', |
'db_ping' => 'mysqli_ping',This maps $smcFunc['db_ping'] directly to the deprecated mysqli_ping function. Any code calling $smcFunc['db_ping']() will trigger deprecation warnings on PHP 8.4 and may fail on PHP 8.5+.
Fix: Replace with a wrapper that executes a lightweight query (e.g., SELECT 1) or uses mysqli_query() to test the connection.
3. SessionHandler return type declarations / #[\ReturnTypeWillChange]
The release-2.1 branch uses a custom session handler class that implements SessionHandlerInterface. Based on the version header comments (e.g., "When 8.1 is the minimum, this can be removed"), these methods use #[\ReturnTypeWillChange] to suppress return type warnings.
In PHP 8.5, #[\ReturnTypeWillChange] may no longer suppress warnings, and the missing return type declarations will generate deprecation notices or errors.
The affected methods that need proper return types are:
| Method | Required Return Type |
|---|---|
open() |
bool |
close() |
bool |
read() |
string|false |
write() |
bool |
destroy() |
bool |
gc() |
int|false |
Fix: Add proper return type declarations to all SessionHandlerInterface methods and remove the #[\ReturnTypeWillChange] attributes. This is safe because SMF 2.1 already requires PHP 7.0+ and return types can be conditionally handled.
4. Passing null to non-nullable parameters of internal functions
PHP 8.1 deprecated passing null to non-nullable string parameters of internal functions. PHP 8.5 promotes these to TypeErrors. The release-2.1 codebase, written for PHP 7.0, does not guard against null in many places.
Related issue: #9173 (strtr() receiving array — open, not in any milestone)
4a. Sources/Subs-Db-mysql.php line 285 — strlen as callback to array_filter
Line 285 in 7438c2b
| return '`' . implode('`.`', array_filter(explode('.', strtr($replacement, array('`' => ''))), 'strlen')) . '`'; |
return '`' . implode('`.`', array_filter(explode('.', strtr($replacement, array('`' => ''))), 'strlen')) . '`';If $replacement is null, strtr() will throw a TypeError in PHP 8.5. Additionally, 'strlen' as a callback to array_filter() will throw a TypeError if any element is null.
4b. Widespread $_SERVER / $_REQUEST / $modSettings values passed directly to string functions
Throughout the codebase, values from superglobals and $modSettings are passed directly to functions like strpos(), strlen(), strtolower(), htmlspecialchars(), trim(), strtr(), substr(), explode(), str_replace(), preg_match(), preg_replace(), urlencode(), rawurlencode(), md5(), sha1(), etc. — all of which require non-null string arguments in PHP 8.5.
Key high-traffic files that need auditing for null-to-string-function calls:
Sources/Load.php— loading settings, user info, themesSources/Subs.php— general utility functionsSources/QueryString.php— URL/request parsing (directly handles$_GET,$_POST,$_REQUEST)Sources/Security.php— security checksSources/LogInOut.php— authenticationSources/Subs-Db-mysql.php— database layerSources/Subs-Compat.php— compatibility functions
Example pattern that breaks:
// If $modSettings['some_key'] is not set, it's null, and strlen(null) is a TypeError in 8.5
if (strlen($modSettings['some_key']) > 0)Fix: Add null coalescing (?? '' or ?? 0) before passing potentially null values to internal functions, or add explicit null checks. A codebase-wide audit using static analysis (e.g., PHPStan level 6+) is strongly recommended.
5. Implicit nullable parameter types (deprecated PHP 8.4)
Since PHP 8.4, function signatures like function foo(string $bar = null) generate a deprecation warning. The correct form is function foo(?string $bar = null).
5a. Sources/Subs-Db-mysql.php — multiple functions
Line 497 in 7438c2b
| function smf_db_insert_id($table, $field = null, $connection = null) |
function smf_db_insert_id($table, $field = null, $connection = null)The $field and $connection parameters default to null but have no type declarations. While this specific case is untyped (and thus unaffected), any typed parameters with = null defaults need the ? nullable prefix. A full audit of all function signatures is needed.
5b. Sources/Subs-Compat.php — mb_ord() and mb_chr() polyfills
Line 197 in 7438c2b
| function mb_ord($string, $encoding = null) |
Line 310 in 7438c2b
| function mb_chr($codepoint, $encoding = null) |
function mb_ord($string, $encoding = null)
function mb_chr($codepoint, $encoding = null)These are polyfills only defined when the native functions don't exist, so they are unlikely to be active on PHP 8.5 (which ships mbstring). However, any similar patterns elsewhere in the codebase with explicit type hints will break.
Fix: Search the entire codebase for the regex pattern function\s+\w+\s*\([^)]*\b\w+\s+\$\w+\s*=\s*null and ensure all typed parameters with = null use ?type syntax.
6. CI/CD does not test PHP 8.5
The GitHub Actions workflow only runs phplint against PHP 7.1–8.4. This is syntax checking only — it will not catch:
- Runtime deprecations promoted to errors
- Type errors from null coercion
- Behavioral changes in internal functions
Fix:
- Add PHP
8.5to the lint matrix. - Ideally, add a runtime test suite (even minimal smoke tests) that exercises key code paths under PHP 8.5.
Summary of required changes
| # | Issue | Severity on 8.5 | Files affected |
|---|---|---|---|
| 1 | trigger_error(E_USER_ERROR) |
🔴 ValueError |
Subs-Db-mysql.php (15+ call sites) |
| 2 | mysqli_ping() deprecated |
🟡 Deprecation warning | Subs-Db-mysql.php (line 61) |
| 3 | Session handler missing return types | 🟡 Deprecation / potential error | Session.php (6 methods) |
| 4 | null to internal string functions |
🔴 TypeError |
Codebase-wide (Load.php, Subs.php, QueryString.php, Security.php, Subs-Db-mysql.php, etc.) |
| 5 | Implicit nullable types | 🟡 Deprecation warning | Any typed parameters with = null |
| 6 | CI doesn't cover PHP 8.5 | 🟠 No safety net | .github/workflows/php.yml |
Recommended approach
- Run PHPStan or Psalm at level 6+ against the
release-2.1branch with PHP 8.5 as the target to surface all null coercion and type issues systematically. - Fix the
trigger_error(E_USER_ERROR)calls first — these are guaranteedValueErrors. - Replace
mysqli_pingwith a query-based connection check. - Add return types to the session handler.
- Add PHP 8.5 to CI.
Version/Git revision
release-2.1 (2.1.7, commit 7438c2b)
Database Engine
All
PHP Version
8.5
Additional Information
Several related issues already exist but are fragmented and incomplete:
- [2.1] Using E_USER_ERROR in trigger_error() deprecated in PHP 8.4 #9141 —
E_USER_ERRORintrigger_error()(open, 2.1.8 milestone) — covers only the DB layer - [3.0 & 2.1]: strtr(): Argument #1 ($string) must be of type string, array given #9173 —
strtr()argument type error (open, no milestone) - [2.1.7]: "Cannot use bool as array" Session.php:159 #9168 — Session.php
Cannot use bool as array(closed/fixed) - [2.1 & 3.0] "Wrong value type sent to the database. IPv4 or IPv6 expected." #9143 — IPv4/IPv6 wrong value type (open, 2.1.8 milestone)
- [2.1] uninitialized permissions should not give error spam #9219
None of these individually address the full scope of PHP 8.5 incompatibility. This issue is intended to serve as a comprehensive tracker.
This is an anonymous contribution made via gitGost.
The original author's identity has been anonymized to protect their privacy.
Activity
jdarwood007 commentedon Apr 5, 2026
This reads like a bot.
live627 commentedon Apr 5, 2026
Unsafe.. BC break. Union types not recognised in PHP 7.
Sesquipedalian commentedon Apr 5, 2026
I'd say most, but not all, of the issues this raises are legitimate. However the proposed solutions are unreliable, as @live627 has already noted for one of them.
Regarding the concerns about typing of parameters that have a null default, SMF 2.1 generally doesn't type its parameters at all, so this report is blowing smoke on that one.
gitgost-anonymous commentedon Apr 9, 2026
@Sesquipedalian Absolutely.
goster-3b92856d · karma (1) · report
sbulen commentedon Apr 9, 2026
TBH, I'd rather see fully tested PRs.
This completely untested AI slop isn't helpful at all.
If we can disable this bot, I suggest we do so.
live627 commentedon Apr 10, 2026
gitgost-anonymous commentedon Apr 10, 2026
I’m the anonymous account who left that comment, and I apologize if it came across as harsh or dismissive. I’m not a contributor to the codebase, but I’d like to contribute in small ways, like reporting issues or sharing feedback, while staying anonymous for personal reasons.
If there’s a better way for anonymous users to give feedback here, I’m happy to follow it.
goster-04a4de5d · karma (1) · report
Sesquipedalian commentedon Apr 10, 2026
I don't have any objection to anonymous reporting.
sbulen commentedon Apr 10, 2026
Help test. Or help validate/reproduce reported issues.
Or submit a fully tested PR.
Anonymity isn't a problem, IMO. Tossing out half-baked, AI generated slop is, though. That's not helping, that's just peanut gallery behavior. Go throw slop elsewhere.
Or help test.
gitgost-anonymous commentedon Apr 11, 2026
Let’s try to keep the tone respectful and get back to the root of the issue.
goster-24d88b48 · karma (1) · report
gitgost-anonymous commentedon Jul 3, 2026
I’d like to ask the repository owners at @SimpleMachines to delete this issue entirely. A couple of comments I received were disrespectful, and I’d like to have my issue taken down.
goster-a65518cf · karma (1) · report
live627 commentedon Jul 3, 2026
I'm okay with closing it, but I'd rather not delete it. The discussion uncovered a few previously untracked issues, and deleting the issue would bury that context. Closing it reduces its visibility while still preserving the history and making those findings discoverable. If needed, the additional issues can be tracked separately and linked from this one.
Sesquipedalian commentedon Jul 6, 2026
Deleting an issue isn't an available option in GitHub, just closing it.
We will close this issue once all the problems have been resolved.
Thanks again for the report!
jdarwood007 commentedon Oct 25, 2026
Part 1 will be resolved with #9795.
Part 2 was fixed in 3.0. We should only address this if mysqli_ping is removed. The only place we natively call the ping is in the upgrader. But it may be expected for mods.
Part 3 SessionHandler issue I am not getting any warnings. So they are still be suppressed as expected
Part 4 There is just too many calls to resolve. The best option is to handle these on a case by case basis.
Part 5 I can't find any calls where we did a type hint with a non null and a null default
function\((.+?)=\s+null(.+?)Part 6 Will be fixed