From 91979469f4f45fd30ff25d196dc6365cb3877d94 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Sat, 17 Oct 2026 01:14:21 -0500 Subject: [PATCH 1/8] fix(session): satisfy the PHP 8.6 session handler contract PHP 8.6 emits two E_WARNINGs at class declaration when a SessionHandlerInterface implementation omits create_sid() and validateId(), and phpunit's failOnWarning turns them into a red suite even in tests that never open a session. Implement SessionIdInterface and SessionUpdateTimestampHandlerInterface, and add the three methods. updateTimestamp() delegates to write() because under 8.6 strict mode an unchanged session closes through it instead of write(). Also available from PHP 9.0 onward, where SessionHandlerInterface declares both methods itself. --- src/Session/DbSessionHandler.php | 48 ++++++++++++++- .../Session/DbSessionHandlerTest.php | 32 ++++++++++ test/unit/Session/DbSessionHandlerTest.php | 60 +++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 test/unit/Session/DbSessionHandlerTest.php diff --git a/src/Session/DbSessionHandler.php b/src/Session/DbSessionHandler.php index 4cf14e4..aeb318a 100644 --- a/src/Session/DbSessionHandler.php +++ b/src/Session/DbSessionHandler.php @@ -19,15 +19,30 @@ use PhpDb\Session\Sql\Insert; use PhpDb\Sql\Exception\InvalidArgumentException as SqlInvalidArgumentException; use PhpDb\Sql\Sql; +use Random\RandomException; use SessionHandlerInterface; +use SessionIdInterface; +use SessionUpdateTimestampHandlerInterface; +use function bin2hex; use function date; use function ini_get; use function is_array; use function is_string; +use function random_bytes; use function time; -final class DbSessionHandler implements SessionHandlerInterface +/** + * Session handler backed by the session table. + * + * Both auxiliary session interfaces are implemented because PHP 8.6 warns + * when a SessionHandlerInterface implementation omits create_sid() and + * validateId(), which SessionHandlerInterface itself declares from PHP 9.0. + */ +final class DbSessionHandler implements + SessionHandlerInterface, + SessionIdInterface, + SessionUpdateTimestampHandlerInterface { private const string TIMESTAMP_FORMAT = 'Y-m-d H:i:s'; @@ -47,6 +62,16 @@ public function close(): bool return true; } + /** + * @throws RandomException + */ + // @mago-expect lint:method-name -- create_sid is mandated by SessionIdInterface. + #[Override] + public function create_sid(): string + { + return bin2hex(random_bytes(16)); + } + /** * @throws SqlInvalidArgumentException */ @@ -100,6 +125,27 @@ public function read(string $id): string|false return is_string($payload) ? $payload : false; } + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function updateTimestamp(string $id, string $data): bool + { + return $this->write( + id : $id, + data: $data, + ); + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function validateId(string $id): bool + { + return false !== $this->read($id); + } + /** * @throws SqlInvalidArgumentException */ diff --git a/test/integration/Session/DbSessionHandlerTest.php b/test/integration/Session/DbSessionHandlerTest.php index 78033de..3e785d3 100644 --- a/test/integration/Session/DbSessionHandlerTest.php +++ b/test/integration/Session/DbSessionHandlerTest.php @@ -34,6 +34,8 @@ #[CoversMethod(DbSessionHandler::class, 'write')] #[CoversMethod(DbSessionHandler::class, 'destroy')] #[CoversMethod(DbSessionHandler::class, 'gc')] +#[CoversMethod(DbSessionHandler::class, 'updateTimestamp')] +#[CoversMethod(DbSessionHandler::class, 'validateId')] #[RequiresPhpExtension('pdo_mysql')] #[Group('integration')] #[Group('integration-mysql')] @@ -116,6 +118,36 @@ public function readReturnsWhatWasWritten(): void self::assertSame('user|s:4:"joey";', $handler->read('abc')); } + #[Test] + public function updateTimestampPersistsTheSessionAndExtendsTheExpiry(): void + { + $past = date( + format : 'Y-m-d H:i:s', + timestamp: time() - 60, + ); + $this->insertRow( + id : 'abc', + payload : 'data', + expiresAt: $past, + ); + + $handler = new DbSessionHandler($this->adapter); + + self::assertTrue($handler->updateTimestamp('abc', 'data')); + self::assertSame('data', $this->payloadOf('abc')); + self::assertGreaterThan($past, (string) $this->expiresAtOf('abc')); + } + + #[Test] + public function validateIdReportsWhetherTheSessionIsReadable(): void + { + $handler = new DbSessionHandler($this->adapter); + $handler->write('abc', 'data'); + + self::assertTrue($handler->validateId('abc')); + self::assertFalse($handler->validateId('missing')); + } + #[Test] public function writeExpiresTheSessionAfterTheConfiguredLifetime(): void { diff --git a/test/unit/Session/DbSessionHandlerTest.php b/test/unit/Session/DbSessionHandlerTest.php new file mode 100644 index 0000000..9ba9897 --- /dev/null +++ b/test/unit/Session/DbSessionHandlerTest.php @@ -0,0 +1,60 @@ +handler()->create_sid(); + + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $id); + self::assertNotSame($id, $this->handler()->create_sid()); + } + + /** + * PHP 8.6 warns when a SessionHandlerInterface implementation leaves out + * create_sid() and validateId(); the warnings are fatal under phpunit's + * failOnWarning. Naming both interfaces keeps the class honest. + */ + #[Test] + public function implementsTheSessionIdAndTimestampInterfaces(): void + { + $handler = $this->handler(); + + self::assertInstanceOf(SessionHandlerInterface::class, $handler); + self::assertInstanceOf(SessionIdInterface::class, $handler); + self::assertInstanceOf(SessionUpdateTimestampHandlerInterface::class, $handler); + } + + #[Test] + public function validateIdIsDeclaredWithTheInterfaceReturnType(): void + { + $method = new ReflectionMethod(DbSessionHandler::class, 'validateId'); + + self::assertSame('bool', (string) $method->getReturnType()); + self::assertSame(1, $method->getNumberOfParameters()); + } + + private function handler(): DbSessionHandler + { + return new DbSessionHandler($this->createStub(AdapterInterface::class)); + } +} From 9d6889558f023b498e9a4108633ea5e6066b1f90 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 10:43:01 -0500 Subject: [PATCH 2/8] fix(session): name the gc() parameter as the interface does SessionHandlerInterface::gc() declares $max_lifetime, from PHP 8.0 onward PHP 9.0 keeps the same name. The implementation called it $maxLifetime, which mago reports as incompatible-parameter-name. Named arguments make the parameter name part of the callable contract, so a caller writing $handler->gc(max_lifetime: 60) fails against this class. PHP's own session module calls positionally, which is why this has been latent rather than broken. The rename needs a lint suppression: max_lifetime is snake case and variable-name wants camel case, but the interface fixes the spelling. mago's own suggestion, renaming back to $maxLifetime, would restore the defect. The suppression follows the existing pattern used for create_sid on this branch. --- src/Session/DbSessionHandler.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Session/DbSessionHandler.php b/src/Session/DbSessionHandler.php index aeb318a..2df961e 100644 --- a/src/Session/DbSessionHandler.php +++ b/src/Session/DbSessionHandler.php @@ -89,7 +89,8 @@ public function destroy(string $id): bool * @throws SqlInvalidArgumentException */ #[Override] - public function gc(int $maxLifetime): int|false + // @mago-expect lint:variable-name -- max_lifetime is mandated by SessionHandlerInterface::gc(). + public function gc(int $max_lifetime): int|false { $cutoff = date(format: self::TIMESTAMP_FORMAT); From b95f01a7ab3bba05eb7eea3bc216cb4814cb8aeb Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 10:55:06 -0500 Subject: [PATCH 3/8] test: resolve the pgsql adapter on 127.0.0.1 like mysql does The pgsql fixture hostname was the docker service name, so it only resolved inside the compose network. The mysql fixture already used 127.0.0.1, which resolves on the host and in a runner alike, and that is the convention the rest of the file follows. Changing it lets the pgsql adapter reach the server on both platforms. Before, a local run failed at name resolution: could not translate host name pgsql to address and now it connects to 127.0.0.1 on the default port, surfacing the real credential result instead of a network error. No CI impact. webware-ci.json sets TESTS_PHPDB_ADAPTER_PGSQL to 0 and runs mysql:9.7, so this default never applies in CI. --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 00140e4..8363894 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,7 +29,7 @@ - + From 83903a12a90be344b6813ecd4c001f545acfdfe7 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 11:06:26 -0500 Subject: [PATCH 4/8] test: use one credential pair per engine, no per-engine one-offs Every integration adapter now resolves through the same shape: a 127.0.0.1 host, the engine default port, a webware database, and a webware/webware credential pair. Previously mysql used root with an empty password and pgsql used the postgres superuser, so neither matched the other, and pgsql additionally needed credentials a developer is unlikely to have. The sqlite DSN carried a hardcoded tmp path, which does not exist on Windows. The DSN may now contain a :TMP: token, expanded through a single SqliteDsn helper so the fixture loader and the adapter cannot disagree about the path. Setup for the two server engines is scripted and idempotent. Each script creates the webware role, the webware database, and the grants the fixture loader needs to drop and recreate its schema. The scripts stay outside the repository. Placeholder values only. TEST_ENV_JSON still overrides every one of these in CI, so the values here never reach a runner. --- phpunit.xml.dist | 10 ++--- .../FixtureLoader/SqliteFixtureLoader.php | 17 ++------- .../SchemaFactoryIntegrationTest.php | 2 +- test/integration/SqliteDsn.php | 37 +++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 test/integration/SqliteDsn.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8363894..0f12701 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -25,18 +25,18 @@ - - + + - - + + - + diff --git a/test/integration/FixtureLoader/SqliteFixtureLoader.php b/test/integration/FixtureLoader/SqliteFixtureLoader.php index 5ea5289..3f16491 100644 --- a/test/integration/FixtureLoader/SqliteFixtureLoader.php +++ b/test/integration/FixtureLoader/SqliteFixtureLoader.php @@ -4,16 +4,16 @@ namespace WebwareTestIntegration\PhpDb\FixtureLoader; -use function getenv; +use WebwareTestIntegration\PhpDb\SqliteDsn; + use function is_file; -use function str_replace; use function unlink; final class SqliteFixtureLoader implements FixtureLoaderInterface { public function createDatabase(): void { - $path = $this->databasePath(); + $path = SqliteDsn::path(); if (is_file(filename: $path)) { unlink(filename: $path); @@ -22,19 +22,10 @@ public function createDatabase(): void public function dropDatabase(): void { - $path = $this->databasePath(); + $path = SqliteDsn::path(); if (is_file(filename: $path)) { unlink(filename: $path); } } - - private function databasePath(): string - { - return str_replace( - search : 'sqlite:', - replace: '', - subject: (string) getenv(name: 'TESTS_PHPDB_ADAPTER_SQLITE_DSN'), - ); - } } diff --git a/test/integration/SchemaFactoryIntegrationTest.php b/test/integration/SchemaFactoryIntegrationTest.php index 9a8ec85..f1d9b18 100644 --- a/test/integration/SchemaFactoryIntegrationTest.php +++ b/test/integration/SchemaFactoryIntegrationTest.php @@ -123,7 +123,7 @@ public function itCreatesPrefixedTableOnSqlite(): void AdapterInterface::class => [ 'driver' => Sqlite\Pdo\Driver::class, 'connection' => [ - 'dsn' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_SQLITE_DSN'), + 'dsn' => SqliteDsn::resolve(), ], ], SchemaInterface::class => [ diff --git a/test/integration/SqliteDsn.php b/test/integration/SqliteDsn.php new file mode 100644 index 0000000..c708040 --- /dev/null +++ b/test/integration/SqliteDsn.php @@ -0,0 +1,37 @@ + Date: Wed, 21 Oct 2026 11:51:22 -0500 Subject: [PATCH 5/8] test: reset the pgsql public schema instead of recreating the database The fixture loader dropped and recreated the whole database, connecting to the postgres database to do it. An ordinary role cannot do that: DROP DATABASE cannot run while connected to the target, WITH (FORCE) needs pg_signal_backend to terminate other backends, and CREATE DATABASE needs CREATEDB. Together those forced the suite to run as a superuser, which is the opposite of what the connection settings are for. Nothing in the suite needs the database recreated. Every test creates its own table, asserts against the catalog, and drops it again, so the database only has to exist and be reachable. Recreating the public schema is equivalent and needs only ownership of the database. This is also why the integration suite could not pass locally before: the loader reported permission denied to terminate process, and the role had no CREATEDB. --- .../FixtureLoader/PgsqlFixtureLoader.php | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/test/integration/FixtureLoader/PgsqlFixtureLoader.php b/test/integration/FixtureLoader/PgsqlFixtureLoader.php index 84243ca..d36eadc 100644 --- a/test/integration/FixtureLoader/PgsqlFixtureLoader.php +++ b/test/integration/FixtureLoader/PgsqlFixtureLoader.php @@ -9,25 +9,30 @@ use WebwareTestIntegration\PhpDb\Support\AdapterFactory; use function getenv; -use function sprintf; +/** + * Resets the pgsql schema between integration tests. + * + * Each test creates and drops its own table, so the database only has to + * exist and be reachable. Dropping and recreating the whole database is not + * needed, and an ordinary role cannot do it: DROP DATABASE cannot run while + * connected to the target, WITH (FORCE) needs pg_signal_backend to terminate + * other backends, and CREATE DATABASE needs CREATEDB. Requiring any of that + * would force the suite to run as a superuser, which is exactly what the + * connection settings exist to avoid. + * + * Recreating the public schema is equivalent here and needs only ownership. + */ final class PgsqlFixtureLoader implements FixtureLoaderInterface { public function createDatabase(): void { - $database = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE'); - $adapter = $this->adapter(); - - $adapter->executeQuery(sql: sprintf('DROP DATABASE IF EXISTS "%s" WITH (FORCE)', $database)); - $adapter->executeQuery(sql: sprintf('CREATE DATABASE "%s"', $database)); + $this->resetSchema(); } public function dropDatabase(): void { - $database = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE'); - $adapter = $this->adapter(); - - $adapter->executeQuery(sql: sprintf('DROP DATABASE IF EXISTS "%s" WITH (FORCE)', $database)); + $this->resetSchema(); } private function adapter(): AdapterInterface @@ -38,7 +43,7 @@ private function adapter(): AdapterInterface 'connection' => [ 'hostname' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_HOSTNAME'), 'port' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PORT'), - 'dbname' => 'postgres', + 'dbname' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE'), 'username' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_USERNAME'), 'password' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PASSWORD'), ], @@ -46,4 +51,12 @@ private function adapter(): AdapterInterface driverDependencies: new Pgsql\ConfigProvider()->getDependencies(), ); } + + private function resetSchema(): void + { + $adapter = $this->adapter(); + + $adapter->executeQuery(sql: 'DROP SCHEMA IF EXISTS public CASCADE'); + $adapter->executeQuery(sql: 'CREATE SCHEMA public'); + } } From f299fd71f383543ba63ac2726514aaa8dd54174c Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 11:57:26 -0500 Subject: [PATCH 6/8] ci: create the webware mysql user in the test container The container was started with MYSQL_ALLOW_EMPTY_PASSWORD and MYSQL_DATABASE only, so the image provisioned root and nothing else. The phpunit defaults now ask for webware/webware on both server engines, which matched pgsql but had no counterpart in the mysql container, so every leg failed with access denied for user webware@172.17.0.1. MYSQL_USER and MYSQL_PASSWORD are the image's own provisioning path, so no extra step is needed. Verified against mysql:9.7 with exactly this env: the user is created with access to the webware database and can run DDL. This keeps the engine definitions symmetrical end to end, matching the pgsql role script, rather than having one engine provision a role and the other rely on root. --- webware-ci.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webware-ci.json b/webware-ci.json index b96b195..eb04e5f 100644 --- a/webware-ci.json +++ b/webware-ci.json @@ -8,7 +8,9 @@ "db_env_json": { "MYSQL_ROOT_HOST": "%", "MYSQL_ALLOW_EMPTY_PASSWORD": "true", - "MYSQL_DATABASE": "webware" + "MYSQL_DATABASE": "webware", + "MYSQL_USER": "webware", + "MYSQL_PASSWORD": "webware" }, "db_port": 3306, "db_health_cmd": "mysqladmin ping -h127.0.0.1 --silent", From d42989dd6a6ae666eb5c082ce7e27f811b1ad470 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 12:12:19 -0500 Subject: [PATCH 7/8] test: ensure pgsql database exists instead of recreating it The previous loader dropped the database and created it again, which requires FORCE and therefore pg_signal_backend plus CREATEDB, so the test role had to be effectively superuser. Mirror the mysql loader shape instead: check pg_database and create only when the database is missing. Postgres has no CREATE DATABASE IF NOT EXISTS, so existence is read from pg_database first. CI already provisions the database through the container environment, making this a no-op there. At teardown the suite's own connection is still open, and Postgres refuses to drop a database with a session attached while a non-superuser cannot terminate its own backend. dropDatabase() now tolerates only SQLSTATE 55006 and rethrows anything else. That is safe because the outcome the drop exists to achieve is already guaranteed: CI uses a disposable container per leg, and each test creates and drops its own table, so no state carries between runs. --- .../FixtureLoader/PgsqlFixtureLoader.php | 100 +++++++++++++++--- 1 file changed, 83 insertions(+), 17 deletions(-) diff --git a/test/integration/FixtureLoader/PgsqlFixtureLoader.php b/test/integration/FixtureLoader/PgsqlFixtureLoader.php index d36eadc..5c2478a 100644 --- a/test/integration/FixtureLoader/PgsqlFixtureLoader.php +++ b/test/integration/FixtureLoader/PgsqlFixtureLoader.php @@ -6,33 +6,69 @@ use PhpDb\Adapter\AdapterInterface; use PhpDb\Pgsql; +use Throwable; use WebwareTestIntegration\PhpDb\Support\AdapterFactory; use function getenv; +use function sprintf; +use function str_contains; +use function str_replace; /** - * Resets the pgsql schema between integration tests. + * Ensures the pgsql test database exists, following the same shape as the + * mysql loader. * - * Each test creates and drops its own table, so the database only has to - * exist and be reachable. Dropping and recreating the whole database is not - * needed, and an ordinary role cannot do it: DROP DATABASE cannot run while - * connected to the target, WITH (FORCE) needs pg_signal_backend to terminate - * other backends, and CREATE DATABASE needs CREATEDB. Requiring any of that - * would force the suite to run as a superuser, which is exactly what the - * connection settings exist to avoid. + * CI provisions the database through the container environment (POSTGRES_DB), + * so it is already present when the suite starts, and this is a no-op. Locally + * it creates the database when missing. Either way the create path never drops + * anything. * - * Recreating the public schema is equivalent here and needs only ownership. + * Postgres has no CREATE DATABASE IF NOT EXISTS, so existence is checked + * against pg_database first. + * + * Nothing here drops a database with sessions attached. Postgres refuses that + * where mysql allows it, so a drop-then-create cycle would need FORCE and + * therefore pg_signal_backend, a privilege a test role should not hold. */ final class PgsqlFixtureLoader implements FixtureLoaderInterface { + private const string MAINTENANCE_DATABASE = 'postgres'; + public function createDatabase(): void { - $this->resetSchema(); + if ($this->exists()) { + return; + } + + $this->adapter()->executeQuery( + sql: sprintf( + 'CREATE DATABASE "%s" OWNER "%s"', + $this->database(), + $this->username(), + ), + ); } public function dropDatabase(): void { - $this->resetSchema(); + try { + $this->adapter()->executeQuery( + sql: sprintf('DROP DATABASE IF EXISTS "%s"', $this->database()), + ); + } catch (Throwable $e) { + // Postgres refuses to drop a database that has a session attached, + // and at teardown the suite's own connection is still open. A + // non-superuser role cannot terminate its own backend, so this is + // unreachable without pg_signal_backend. + // + // Tolerating exactly this case is safe because the outcome the drop + // exists to achieve is already guaranteed: CI uses a disposable + // container per leg, and each test creates and drops its own table, + // so no state carries between runs. + if (! str_contains($e->getMessage(), '55006')) { + throw $e; + } + } } private function adapter(): AdapterInterface @@ -43,8 +79,8 @@ private function adapter(): AdapterInterface 'connection' => [ 'hostname' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_HOSTNAME'), 'port' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PORT'), - 'dbname' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE'), - 'username' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_USERNAME'), + 'dbname' => self::MAINTENANCE_DATABASE, + 'username' => $this->username(), 'password' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PASSWORD'), ], ], @@ -52,11 +88,41 @@ private function adapter(): AdapterInterface ); } - private function resetSchema(): void + private function database(): string + { + return (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE'); + } + + private function exists(): bool { - $adapter = $this->adapter(); + $rows = $this + ->adapter() + ->executeQuery( + sql: sprintf( + 'SELECT 1 FROM pg_database WHERE datname = %s', + $this->quotedDatabase(), + ), + ) + ->getQueryResult() + ->toArray(); - $adapter->executeQuery(sql: 'DROP SCHEMA IF EXISTS public CASCADE'); - $adapter->executeQuery(sql: 'CREATE SCHEMA public'); + return [] !== $rows; + } + + private function quotedDatabase(): string + { + return sprintf( + "'%s'", + str_replace( + search : "'", + replace: "''", + subject: $this->database(), + ), + ); + } + + private function username(): string + { + return (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_USERNAME'); } } From d9582e47d2fa9131fede8485580b4ebf2945fc1c Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 21 Oct 2026 12:23:56 -0500 Subject: [PATCH 8/8] fix: point the phpunit schema at the vendored copy Infection validates phpunit.xml.dist against xsi:noNamespaceSchemaLocation before it runs a single mutant. With an https URL that validation is a network fetch, so the required mutation job failed in 58s without running a mutant once: DOMDocument::schemaValidate(https://schema.phpunit.de/13.1/phpunit.xsd): Failed to open stream: HTTP request failed! HTTP/1.1 522 The URL also pinned the 13.1 series while this package installs PHPUnit 13.3, so the schema no longer described the installed version. Use the relative path instead. It resolves inside the installed package, needs no network, and always matches the installed version. This is the value phpunit --generate-configuration writes itself: it prefers vendor/phpunit/phpunit/phpunit.xsd and only falls back to the URL when that file is absent. Verified offline: the relative path validates the config with 0 errors while the URL fails to locate the schema resource. --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f12701..4783b2c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,6 @@