ezpSessionHandlerDB PHP 8 compatibility bugfixes and PHPUnit 13 test suite
Read this page if your installation stores sessions in the database (ezpSessionHandlerDB) and runs on
PHP 8, if your ezsession table keeps growing, or if you saw blank pages with "Cannot call session save handler in a
recursive manner". Three bugs in ezpSessionHandlerDB caused silent session failures under PHP 8 and stopped garbage
collection from ever removing expired sessions. All three are fixed (6.0.x, 2026-04-08; released in 6.0.13) and
covered by a PHPUnit 13 test suite that runs without a live database or the Exponential kernel.
In short
| What changed | read() returns '' instead of false; gc() compares with time(); the gc() timeout guard measures the real elapsed time; setSaveHandler() no longer calls session_module_name( 'user' ) (6.0.15). |
| Who is affected | Every installation with database sessions on PHP 8.0 or newer (PHP 7.4 usable without support). |
| How to check | Count expired rows: SELECT COUNT(*) FROM ezsession WHERE expiration_time < UNIX_TIMESTAMP(); (MySQL). Before the fix this number only grew. |
| How to fix | Update. Expired rows left from before are removed by the next garbage collection run. |
Affected class: lib/ezsession/classes/ezpsessionhandlerdb.php. Requires PHP 8.0 or newer; tests use PHPUnit 13.0.0.
Bug 1: read() returned false instead of ''
What broke
SessionHandlerInterface::read() must return a string on every code path — an empty
string '' signals "no session data", while false signals a fatal handler error. In
PHP 7 the distinction was not enforced. PHP 8 made it strict: returning false causes
PHP to emit:
Cannot call session save handler in a recursive mannerand then immediately attempt to restart the session, resulting in an infinite loop or a blank page.
Affected paths
| Path | Old return | Fixed return |
|---|---|---|
| DB not connected | return false | return '' |
| No matching session row | return false | return '' |
What changed
// Before
if ( !$db->isConnected() )
return false;
// After
if ( !$db->isConnected() )
return ''; // PHP 8: '' = no data, false = fatal errorBug 2: gc() WHERE clause used $maxLifeTime (duration) not time() (timestamp)
What broke
PHP's automatic garbage collector calls gc( $maxLifeTime ) where $maxLifeTime is the
value of session.gc_maxlifetime — a duration in seconds (e.g. 1440).
ezsession.expiration_time is stored as an absolute Unix timestamp
(time() + SessionTimeout, typically ~1744000000 in 2026). The query:
DELETE FROM ezsession WHERE expiration_time < 1440never matches any row, so expired sessions were never deleted.
What changed
Both the iterating and non-iterating code paths now use time():
// Before
WHERE expiration_time < $maxLifeTime -- always false: 1744xxxxxx < 1440
// After
WHERE expiration_time < ' . time() . ' -- correct: absolute timestamp comparisonBug 3: gc() timeout guard used $maxLifeTime as a start timestamp
What broke
The iterating path checks remaining execution time after each batch to avoid hitting the HTTP server timeout. The guard was:
$remaningTime = $maxExecutionTime - GC_TIMEOUT_MARGIN - ( $stopTime - $maxLifeTime );$maxLifeTime (e.g. 1440) was subtracted as if it were a Unix timestamp. The actual
elapsed time therefore evaluated to roughly time() − 1440 ≈ 1 744 000 000 seconds —
a nonsensical value orders of magnitude larger than $maxExecutionTime. The timeout
guard fired immediately after the first batch, so the iterating GC always stopped
after one batch even when ample execution time remained.
What changed
A $gcStartTime variable is captured once before the loop, and the guard uses that:
// Before
$remaningTime = $maxExecutionTime - GC_TIMEOUT_MARGIN - ( $stopTime - $maxLifeTime );
// After
$gcStartTime = time(); // captured before the do-while loop
// ... inside loop ...
$remaningTime = $maxExecutionTime - GC_TIMEOUT_MARGIN - ( $stopTime - $gcStartTime );Bug 4: setSaveHandler() called session_module_name( 'user' )
Fixed on 5 October 2026, in the 6.0.15 line.
What broke
ezpSessionHandler::setSaveHandler() (lib/ezsession/classes/ezpsessionhandler.php) selected the user module
before it registered the callbacks:
session_module_name( 'user' );
session_set_save_handler( ... );Since PHP 8.0, session_module_name() refuses the name user with
ValueError: session_module_name(): Argument #1 ($module) cannot be "user". The error stopped every request of an
installation with [Session] Handler=ezpSessionHandlerDB, and of every handler of an extension that inherits
setSaveHandler(). The default ezpSessionHandlerPHP and ezpSessionHandlerSymfony have their own
setSaveHandler() and were not affected.
What changed
The call is gone. session_set_save_handler() selects the user module by itself, so the handler is registered as
before.
PHP 8.4 still reported a deprecation for session_set_save_handler() with single callbacks instead of an object that
implements SessionHandlerInterface; bug 5 below removes it.
Bug 5: setSaveHandler() gave PHP six separate callbacks
What broke
Since PHP 8.4 every request of an installation with [Session] Handler=ezpSessionHandlerDB (or an extension handler
that inherits setSaveHandler()) logged:
session_set_save_handler(): Providing individual callbacks instead of an object implementing SessionHandlerInterface is deprecatedWhat changed
setSaveHandler() now gives PHP an object: ezpSessionHandlerAdapter (lib/ezsession/classes/ezpsessionhandleradapter.php)
implements SessionHandlerInterface and passes open, close, read, write, destroy and gc on to the handler
unchanged. So:
- Handlers stay as they are. An extension handler that extends
ezpSessionHandlerkeeps its methods; nothing in it has to change. The adapter is available to any code that registers a handler itself:session_set_save_handler( new ezpSessionHandlerAdapter( $handler ), false ). - No shutdown function is registered (
false), exactly as with the callbacks before: eZSession and the persistent workers of Exponential Velocity write and close the session themselves. - PHP 8.0 to 8.5: the object form works on every supported version. The adapter's methods carry
#[\ReturnTypeWillChange]instead of return types (the interface has tentative return types since PHP 8.1, and the code style of Exponential has no return types).
ezpSessionHandlerPHP and ezpSessionHandlerSymfony have their own setSaveHandler() and are unchanged.
Tests: tests/tests/lib/ezsession/EzpSessionHandlerAdapterTest.php (every call passed on unchanged, the adapter is a
SessionHandlerInterface, setSaveHandler() raises no deprecation and selects the user module).
PHPUnit 13 test suite
A new test file covers all four bugs without a live database or the Exponential kernel:
tests/tests/lib/ezsession/EzpSessionHandlerDBPhp8BugfixesTest.phpHand-rolled stubs replace eZDB, eZINI, eZSession, and ezpEvent so the class
loads in pure unit-test mode. The suite uses createStub() throughout (PHPUnit 13
preferred API — no getMockBuilder() notices).
Test list
| Test | Bug verified |
|---|---|
testReadReturnsEmptyStringWhenDatabaseNotConnected | Bug 1 |
testReadReturnsEmptyStringWhenNoSessionRowFound | Bug 1 |
testReadReturnsSessionDataWhenRowFound | Bug 1 regression guard |
testReadReturnTypeIsAlwaysString | Bug 1 |
testGcIteratingPathUsesCurrentTimestampNotMaxLifetime | Bug 2 |
testGcNonIteratingPathUsesCurrentTimestampNotMaxLifetime | Bug 2 |
testGcDoesNotUseDurationLiteralInWhereClause | Bug 2 |
testGcDoesNotPrematurelyTimeOutWithReasonableMaxLifetime | Bug 3 |
testGcTimeoutGuardUsesElapsedTimeNotMaxLifetime | Bug 3 |
testGcNonIteratingPathReturnsTrue | Regression guard |
testGcIteratingPathReturnsTrueWhenNoExpiredSessions | Regression guard |
testHandlerImplementsRequiredMethods | API contract guard |
testSetSaveHandlerRegistersTheUserModule | Bug 4 |
Run
php vendor/phpunit/phpunit/phpunit \
tests/tests/lib/ezsession/EzpSessionHandlerDBPhp8BugfixesTest.php \
--no-coverageExpected output:
PHPUnit 13.0.0 by Sebastian Bergmann and contributors.
Runtime: PHP 8.5.5
............. 13 / 13 (100%)
OK (13 tests, 30 assertions)