From 4c72813a68d7796e7ed94df98178f6dba55cea21 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:42:24 +0400 Subject: [PATCH 1/6] [#595] Declare the return types PHP 8.1 expects on ResponseHeaders and DatabaseHandler On PHP 8.1 and newer, ResponseHeaders (offsetGet, current, key) and DatabaseHandler (gc) raise a deprecation when the class loads, because the interface methods they implement have return types. Quantum's error handler turns a reported deprecation into an exception, which is a fatal error during class loading. The methods now declare mixed and int|false, which are valid on PHP 8.0 too. --- src/HttpClient/ResponseHeaders.php | 6 ++-- .../Adapters/Database/DatabaseHandler.php | 2 +- tests/Unit/HttpClient/ResponseHeadersTest.php | 28 ++++++++++++++++ .../Adapters/Database/DatabaseHandlerTest.php | 33 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php diff --git a/src/HttpClient/ResponseHeaders.php b/src/HttpClient/ResponseHeaders.php index 5e0dcc1f5..842911a7a 100644 --- a/src/HttpClient/ResponseHeaders.php +++ b/src/HttpClient/ResponseHeaders.php @@ -82,7 +82,7 @@ public function offsetUnset($offset): void * @param string $offset * @return mixed|null */ - public function offsetGet($offset) + public function offsetGet($offset): mixed { return $this->data[strtolower($offset)] ?? null; } @@ -95,7 +95,7 @@ public function count(): int /** * @return mixed */ - public function current() + public function current(): mixed { return current($this->data); } @@ -108,7 +108,7 @@ public function next(): void /** * @return string|null */ - public function key() + public function key(): mixed { $key = key($this->data); diff --git a/src/Session/Adapters/Database/DatabaseHandler.php b/src/Session/Adapters/Database/DatabaseHandler.php index 32549aaf1..e20a6edb6 100644 --- a/src/Session/Adapters/Database/DatabaseHandler.php +++ b/src/Session/Adapters/Database/DatabaseHandler.php @@ -87,7 +87,7 @@ public function destroy($id): bool /** * @inheritDoc */ - public function gc($max_lifetime) + public function gc($max_lifetime): int|false { $old = time() - $max_lifetime; diff --git a/tests/Unit/HttpClient/ResponseHeadersTest.php b/tests/Unit/HttpClient/ResponseHeadersTest.php index 300978521..6a8766862 100644 --- a/tests/Unit/HttpClient/ResponseHeadersTest.php +++ b/tests/Unit/HttpClient/ResponseHeadersTest.php @@ -4,6 +4,7 @@ use Quantum\HttpClient\ResponseHeaders; use Quantum\Tests\Unit\AppTestCase; +use ReflectionMethod; class ResponseHeadersTest extends AppTestCase { @@ -33,4 +34,31 @@ public function testResponseHeadersCanUnsetCaseInsensitively(): void $this->assertCount(0, $headers); $this->assertNull($headers['Content-Type']); } + + /** + * @dataProvider interfaceMethodProvider + */ + public function testResponseHeadersDeclaresTheReturnTypeOfItsInterfaceMethods(string $method): void + { + $this->assertTrue((new ReflectionMethod(ResponseHeaders::class, $method))->hasReturnType()); + } + + /** + * @return array + */ + public function interfaceMethodProvider(): array + { + return [ + 'offsetSet' => ['offsetSet'], + 'offsetExists' => ['offsetExists'], + 'offsetUnset' => ['offsetUnset'], + 'offsetGet' => ['offsetGet'], + 'count' => ['count'], + 'current' => ['current'], + 'next' => ['next'], + 'key' => ['key'], + 'valid' => ['valid'], + 'rewind' => ['rewind'], + ]; + } } diff --git a/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php new file mode 100644 index 000000000..29a86c258 --- /dev/null +++ b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php @@ -0,0 +1,33 @@ +assertTrue((new ReflectionMethod(DatabaseHandler::class, $method))->hasReturnType()); + } + + /** + * @return array + */ + public function interfaceMethodProvider(): array + { + return [ + 'open' => ['open'], + 'close' => ['close'], + 'read' => ['read'], + 'write' => ['write'], + 'destroy' => ['destroy'], + 'gc' => ['gc'], + ]; + } +} From 1ee093909c3be1a64667f2bded6b8e8718a9687f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:48:13 +0400 Subject: [PATCH 2/6] [#595] Require PHP 8.1 composer.json requires ^8.1 and PHPStan analyses the code as PHP 8.1. Applications on PHP 8.0 stay on 3.0.x. --- composer.json | 2 +- phpstan.neon.dist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 8cd0c3b51..c6da2a892 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "ext-pdo": "*", "ext-curl": "*", "ext-json": "*", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 6cef9766b..59480f89d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - phpVersion: 80000 + phpVersion: 80100 level: 8 scanFiles: From 514dcafeb6486e534f5e79715e6d6946a506db2a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:23 +0400 Subject: [PATCH 3/6] [#595] Test PHP 8.1, 8.2 and 8.3 in CI The matrix drops PHP 8.0, which no longer receives security fixes and is below the new minimum, and adds 8.3. --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index bd8026f7d..b1ff3bf36 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: operating-system: [ ubuntu-latest ] - php-versions: [ '8.0', '8.1', '8.2' ] + php-versions: [ '8.1', '8.2', '8.3' ] redis-version: [ 6 ] runs-on: ${{ matrix.operating-system }} From 063931e7071078c4661dae566214331816a8325f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:11:47 +0400 Subject: [PATCH 4/6] [#595] Test DatabaseHandler::gc() gc() had no test. It now has one for deleting the expired sessions and keeping the others, and one for returning false when the sessions cannot be deleted. --- .../Adapters/Database/DatabaseHandlerTest.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php index 29a86c258..4027b0efa 100644 --- a/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php +++ b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php @@ -3,11 +3,57 @@ namespace Quantum\Tests\Unit\Session\Adapters\Database; use Quantum\Session\Adapters\Database\DatabaseHandler; +use Quantum\Database\Adapters\Idiorm\IdiormDbal; +use Quantum\Tests\Unit\Session\TestCaseHelper; +use Quantum\Database\Contracts\DbalInterface; +use Quantum\Model\Factories\ModelFactory; use Quantum\Tests\Unit\AppTestCase; use ReflectionMethod; +use Mockery; class DatabaseHandlerTest extends AppTestCase { + use TestCaseHelper; + + public function setUp(): void + { + parent::setUp(); + + IdiormDbal::connect(['driver' => 'sqlite', 'database' => ':memory:']); + + $this->_createSessionsTable(); + } + + public function tearDown(): void + { + ModelFactory::createDynamicModel('sessions')->deleteMany(); + + parent::tearDown(); + } + + public function testDatabaseHandlerGcDeletesTheExpiredSessionsAndKeepsTheOthers(): void + { + $handler = new DatabaseHandler(ModelFactory::createDynamicModel('sessions')->getOrmInstance()); + + $handler->write('expired', 'old data'); + $handler->write('active', 'new data'); + + IdiormDbal::execute('UPDATE sessions SET ttl = ? WHERE session_id = ?', [time() - 7200, 'expired']); + + $this->assertSame(0, $handler->gc(3600)); + + $this->assertSame([['session_id' => 'active']], IdiormDbal::query('SELECT session_id FROM sessions')); + } + + public function testDatabaseHandlerGcReturnsFalseWhenTheSessionsCannotBeDeleted(): void + { + $model = Mockery::mock(DbalInterface::class); + $model->shouldReceive('criteria')->with('ttl', '<', Mockery::type('int'))->andReturnSelf(); + $model->shouldReceive('deleteMany')->andReturn(false); + + $this->assertFalse((new DatabaseHandler($model))->gc(3600)); + } + /** * @dataProvider interfaceMethodProvider */ From 7d9f785d0f7902c65ccf07f9e23a81f73844ca1d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:40:26 +0400 Subject: [PATCH 5/6] [#595] Return the number of deleted sessions from DatabaseHandler::gc() SessionHandlerInterface::gc() returns the number of deleted sessions, or false on failure. The handler returned 0 after any successful delete, and returned false when SleekDB had nothing to delete. It now counts the expired sessions first: 0 when there are none, otherwise the number deleted. The tests insert the sessions directly, because one handler serves one session. --- .../Adapters/Database/DatabaseHandler.php | 11 ++++--- .../Adapters/Database/DatabaseHandlerTest.php | 32 +++++++++++++++---- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/Session/Adapters/Database/DatabaseHandler.php b/src/Session/Adapters/Database/DatabaseHandler.php index e20a6edb6..94f852678 100644 --- a/src/Session/Adapters/Database/DatabaseHandler.php +++ b/src/Session/Adapters/Database/DatabaseHandler.php @@ -85,18 +85,19 @@ public function destroy($id): bool } /** - * @inheritDoc + * Deletes the expired sessions + * @return int|false The number of deleted sessions, or false when they cannot be deleted */ public function gc($max_lifetime): int|false { $old = time() - $max_lifetime; - $result = $this->sessionModel->criteria('ttl', '<', $old)->deleteMany(); + $expired = $this->sessionModel->criteria('ttl', '<', $old)->count(); - if ($result === false) { - return false; + if ($expired === 0) { + return 0; } - return 0; + return $this->sessionModel->criteria('ttl', '<', $old)->deleteMany() ? $expired : false; } } diff --git a/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php index 4027b0efa..398d7f99e 100644 --- a/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php +++ b/tests/Unit/Session/Adapters/Database/DatabaseHandlerTest.php @@ -31,16 +31,22 @@ public function tearDown(): void parent::tearDown(); } - public function testDatabaseHandlerGcDeletesTheExpiredSessionsAndKeepsTheOthers(): void + public function testDatabaseHandlerGcDeletesTheExpiredSessionsAndReturnsTheirNumber(): void { - $handler = new DatabaseHandler(ModelFactory::createDynamicModel('sessions')->getOrmInstance()); + $this->insertSession('expired-1', time() - 7200); + $this->insertSession('expired-2', time() - 5400); + $this->insertSession('active', time()); - $handler->write('expired', 'old data'); - $handler->write('active', 'new data'); + $this->assertSame(2, $this->createHandler()->gc(3600)); - IdiormDbal::execute('UPDATE sessions SET ttl = ? WHERE session_id = ?', [time() - 7200, 'expired']); + $this->assertSame([['session_id' => 'active']], IdiormDbal::query('SELECT session_id FROM sessions')); + } + + public function testDatabaseHandlerGcReturnsZeroWhenNoSessionHasExpired(): void + { + $this->insertSession('active', time()); - $this->assertSame(0, $handler->gc(3600)); + $this->assertSame(0, $this->createHandler()->gc(3600)); $this->assertSame([['session_id' => 'active']], IdiormDbal::query('SELECT session_id FROM sessions')); } @@ -49,6 +55,7 @@ public function testDatabaseHandlerGcReturnsFalseWhenTheSessionsCannotBeDeleted( { $model = Mockery::mock(DbalInterface::class); $model->shouldReceive('criteria')->with('ttl', '<', Mockery::type('int'))->andReturnSelf(); + $model->shouldReceive('count')->andReturn(2); $model->shouldReceive('deleteMany')->andReturn(false); $this->assertFalse((new DatabaseHandler($model))->gc(3600)); @@ -62,6 +69,19 @@ public function testDatabaseHandlerDeclaresTheReturnTypeOfItsInterfaceMethods(st $this->assertTrue((new ReflectionMethod(DatabaseHandler::class, $method))->hasReturnType()); } + /** + * A handler serves one session, so every check gets a handler of its own + */ + private function createHandler(): DatabaseHandler + { + return new DatabaseHandler(ModelFactory::createDynamicModel('sessions')->getOrmInstance()); + } + + private function insertSession(string $id, int $ttl): void + { + IdiormDbal::execute('INSERT INTO sessions (session_id, data, ttl) VALUES (?, ?, ?)', [$id, 'data', $ttl]); + } + /** * @return array */ From 3acc1719467128396594e801b781189c13364bf4 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:40:27 +0400 Subject: [PATCH 6/6] [#595] Move Rector to the PHP 8.1 level set ReadOnlyPropertyRector and the two first-class callable rules are skipped, because applying them changes 52 files and breaks four tests. They are applied in a separate change. --- rector.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rector.php b/rector.php index aff633307..9920973ba 100644 --- a/rector.php +++ b/rector.php @@ -4,6 +4,9 @@ use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; +use Rector\CodingStyle\Rector\FuncCall\FunctionFirstClassCallableRector; +use Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector; +use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; use Rector\Config\RectorConfig; @@ -13,7 +16,7 @@ __DIR__ . '/src', ]) ->withSets([ - LevelSetList::UP_TO_PHP_80, + LevelSetList::UP_TO_PHP_81, SetList::TYPE_DECLARATION, ]) ->withRules([ @@ -21,4 +24,7 @@ ]) ->withSkip([ ClassPropertyAssignToConstructorPromotionRector::class, + ReadOnlyPropertyRector::class, + FunctionFirstClassCallableRector::class, + ArrayToFirstClassCallableRector::class, ]);