Skip to content

[2.1]: Full PHP 8.5 compatibility audit and fixes #9179

@gitgost-anonymous

Description

@gitgost-anonymous

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.phpsmf_db_error_backtrace()

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 195smf_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

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()

SMF/Sources/Subs-Compat.php

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.

'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

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, themes
  • Sources/Subs.php — general utility functions
  • Sources/QueryString.php — URL/request parsing (directly handles $_GET, $_POST, $_REQUEST)
  • Sources/Security.php — security checks
  • Sources/LogInOut.php — authentication
  • Sources/Subs-Db-mysql.php — database layer
  • Sources/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

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.phpmb_ord() and mb_chr() polyfills

function mb_ord($string, $encoding = null)

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

https://github.com/SimpleMachines/SMF/blob/7438c2b04807bca3b3160801a3d40fd2f0f0ca7b/.github/workflows/php.yml

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:

  1. Add PHP 8.5 to the lint matrix.
  2. 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

  1. Run PHPStan or Psalm at level 6+ against the release-2.1 branch with PHP 8.5 as the target to surface all null coercion and type issues systematically.
  2. Fix the trigger_error(E_USER_ERROR) calls first — these are guaranteed ValueErrors.
  3. Replace mysqli_ping with a query-based connection check.
  4. Add return types to the session handler.
  5. 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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions