Skip to content

5 October 2026

13 changes:

  • Deleting a role deletes its grants
  • Event::listen() does not register the same listener twice
  • Erasing an account deletes its passkeys, devices, roles, memberships and notifications
  • createTestUser(): a test's users are erased when it ends
  • What a test class leaves behind is removed and named, and an overgrown test database is rebuilt
  • An OAuth2 access token survives a second of clock skew, and five intermittent tests stop failing
  • The framework's suite: 380 s of tests down to 303
  • Three more slow test classes: 303 s down to 288
  • symfony/console 6.4
  • The framework's suite runs in parallel: ./dockertest --parallel
  • Flaky tests: the clock and one-second margins
  • The suite passes on a database built from nothing
  • Tests that passed or failed by the order they ran in

Deleting a role deletes its grants

Role::delete() removed the role, its user assignments and its group assignments, but left every authserver.permissions row with subject_type = 'role' for it. Those rows matched nobody, but they kept accumulating. One project's test database held 326,492 grants of 26,330 deleted roles, and that project deleted the grants itself before calling delete().

Fixed

  • Role::delete() deletes the role's grants and flushes the permission cache. A user's grant that happens to have the same number as the role is left alone. Integration test on MySQL and PostgreSQL; it fails without the fix.

Event::listen() does not register the same listener twice

An application registers its listeners in init(). When the application started again in the same process, which a test suite does for every test, listen() appended another copy. 3,000 tests into one suite, a listener on invitation.accepted ran 3,000 times for each acceptance. Those tests took 12 s each in the suite, against 0.3 s alone.

Fixed

  • A registration identical to one already there, for the same event and priority, is ignored. For a closure, "identical" means written at the same place, in the same class, having captured the same values. The bound $this is not compared, because a fresh application object on every start is the duplicate. A loop that registers one closure per item keeps all of them, because the captured values differ.

Upgrading

A behaviour change; see the Upgrade Guide. Code that registered the same listener twice on purpose, to run it twice, now gets one registration.

Documentation

Erasing an account deletes its passkeys, devices, roles, memberships and notifications

The erase that Account::eraseUserData() performs for Article 17 covered six framework tables plus users. It missed seven others, and none of them has a cascading foreign key to users: - passkey credentials and trusted devices, which are ways to sign in to an id nobody owns any more; - role assignments and organisation memberships; - push subscriptions, the push log and push tests.

It also left the notifications addressed to the account. This was found while building test cleanup on top of the erase, because a test needs exactly the same answer to "what belongs to this person".

Fixed

  • Pramnos\Auth\AccountErasure::erase($userId) holds the erase now. eraseUserData() calls it, with the same signature. It deletes the seven tables above, plus the notifications addressed to a user class. A notification addressed to something else with the same id is left alone.
  • Integration tests on MySQL and PostgreSQL use tables built from their migrations. They cover three cases: every row of the erased account goes and every row of another account stays; an organisation's notification with the same id stays; a refusing listener leaves everything in place.

Upgrading

Accounts erased before this still have those rows. The Upgrade Guide says how to find them.

Documentation

createTestUser(): a test's users are erased when it ends

Each of the three applications on the framework had written its own version of "create a user, remember it, delete it and what hangs off it". Each did it after its suite slowed down under the users nobody removed: - one suite went from 13:35 to 1:20 once its users were cleaned up; - another held 5,079 administrators and took 22 minutes; - a third had 7,619 tokens on one account.

The framework offered no way to create a user in a test, only loginUser($userId).

Added

  • BaseTestCase::createTestUser(array $attributes = []). It creates a user with a unique username and email, sets any users column given, hashes a plain password the framework's way, and returns the id.
  • BaseTestCase::trackTestUser($userId), which hands over a user created by the code under test.
  • BaseTestCase::tearDown(), which erases them all through AccountErasure. That covers the framework's tables, and the application's rows through its account.data_erase listeners. A failed erase is a test error.
  • Integration tests on MySQL and PostgreSQL. They cover creating a user, signing in with its password hash, erasing it with the listener heard, a tracked user, and a second tearDown() that erases nothing.

The names avoid the helpers applications already have (createUser, trackUser, createAdministrator), so no subclass meets a signature it cannot match.

Documentation

What a test class leaves behind is removed and named, and an overgrown test database is rebuilt

createTestUser() covers the users a test creates itself. It does not cover a sign-up that the code under test performs, or a row inserted by hand. Nothing noticed those either: in every project, the first sign of a leak was a suite that had become slow.

Added

  • A watch on tables. BaseTestCase notes the highest key in each $watchedTables table when a class starts. users is watched by default, and an application adds its own. When the class ends, every row above that key that is still there is removed: users through AccountErasure, other rows by DELETE. When the suite ends, it prints which classes left what on STDERR. A class whose own setUp() does not chain calls watchForRowsLeftBehind().
  • A rebuild above a limit. 'test_database' => ['rebuild_above' => [table => rows]] in the test settings. Before the run, a table over its limit makes TestEnvironment drop the test database, so the run builds it from nothing. The setting is opt-in.
  • Integration tests:
  • a user inserted by hand is removed at the end of its class and reported against it;
  • the rebuild drops a database whose table is past its limit, keeps one at the limit or with the table absent, and ignores a missing database.

Both run on MySQL and PostgreSQL.

The report names the class instead of failing it. A strict mode can come later, once applications have cleaned up what the report shows them.

Upgrading

A test class that relied on users made in another class finds them removed. See the Upgrade Guide.

Documentation

An OAuth2 access token survives a second of clock skew, and five intermittent tests stop failing

Five tests failed now and then in a full run, and passed alone. Four of them had one cause: the wall clock of a virtual machine (here WSL2) is corrected in steps, sometimes backwards, so less time appeared to pass than really did. The fifth depended on what an earlier test had left in the session.

Fixed

  • The OAuth2 resource server allows 60 seconds of clock skew. This is the one change that affects production, not only tests. createResourceServer() built its token validator with no allowance, so a token was refused as "not yet valid" whenever the checking server was a second behind the one that issued it: another host, or this one after a correction. That was the intermittent failure of FullAuthorizationCodeFlowTest. The framework's own JWT middleware have always allowed 60 seconds, and so does this now, through OAuth2ServerFactory::CLOCK_LEEWAY_SECONDS.
  • Tests that measured time with the wall clock:
  • DeadlockRetryTest measures with hrtime(). A 600 ms back-off had once measured as 211 ms.
  • StructuredOperationParityTest waits until time() itself has moved past the TTL.
  • DatabaseDriverTest waits on the clock the driver reads, instead of on sleep(1).
  • TrackingInfoPostgreSQLTest clears $_SESSION['uid'] as well as the $_SERVER keys. A user signed in by an earlier test turned Reporting into Reporting_u4601.

Documentation

The framework's suite: 380 s of tests down to 303

The time the tests themselves took had grown from 149.5 s (15,693 tests) to 380.6 s (18,210). A JUnit log and a profile showed that 42 classes accounted for about 210 s, and that the other 17,400 tests had a median of 0.1 ms. These changes are all in the framework's own test setup, so no application is affected.

Changed

  • Schema once per class. QueueManager{MySQL,PostgreSQL}Test, OauthTest and TokensControllerTest rebuilt their tables from the migrations before every test. They now build the tables once, empty them per test, and drop them when the class ends. Measured:
  • the queue classes: 33 s → 6 s;
  • the two OAuth classes: 22.5 s → 7.2 s;
  • the tokens controller: 12.6 s → 1.9 s.
  • One throwaway database per class. GroupRolesTest, OrganizationScopedChecksTest and AccountErasureTest created a database per test. A new trait, tests/Support/ReusesProbeDatabase, builds it once per class and engine and empties it per test: 11.2 s → 3.2 s.
  • resolveDependencies="false" in phpunit.xml. No test uses #[Depends], and PHPUnit's resolution across 18,000 tests was 47% of a run filtered to one class: 2.7 s → 1.6 s.
  • The TimescaleDB container runs with fsync=off and full_page_writes=off. These match the MySQL container's existing my.cnf. The container keeps no volume. The PostgreSQL-heavy classes: 26 s → 21 s.

Fixed

  • FrameworkMigrationsPostgreSQLTest wrote applications.appid = 1 explicitly into the shared test database and left it there. The sequence stayed behind, and the next ordinary insert, in ApplicationServicePostgreSQLTest, failed with a duplicate key. That was invisible for as long as the container kept an old database. The test now takes the id from the sequence and deletes its row.

Documentation

Three more slow test classes: 303 s down to 288

Changed

  • MassMessageDispatcherTest drops and rebuilds its three messaging tables once per class, and empties them for every other test: 12.2 s → 1.4 s. The migrations still run for each test. They are idempotent and cheap, and they bring back the tables a test drops on purpose.
  • GroupsScreenTest uses ReusesProbeDatabase: 7.3 s → about 1 s. The test that drops the group tables calls the trait's new probeChanged(), so the next test rebuilds them.
  • RbacFunctionsCharacterizationTest installs the RBAC schema once per class and truncates its tables per test: 4.9 s → 0.8 s.
  • ReusesProbeDatabase empties PostgreSQL tables without RESTART IDENTITY. Like MySQL's DELETE, ids keep rising across tests. Restarting them handed id 1, the system account, to the first user a test created.

symfony/console 6.4

symfony/console had been pinned to 5.4.* since the Symfony components were first added, with no reason recorded. It is now ^5.4 || ^6.4, and a composer update of the framework installs 6.4.47. What prompted the change: no PHPUnit-11-compatible ParaTest installs alongside 5.4.

Changed

  • composer.json: "symfony/console": "^5.4 || ^6.4". An application that needs to stay on 5.4 for a while still resolves, and composer update takes 6.4.
  • Serve and MigrateLogs declare execute(): int and configure(): void, like the other 64 framework commands.
  • The full suite passes on 6.4 unchanged: 18,210 tests.

What breaks in subclasses

Under 6.x, execute() must return an int. A command that reaches a bare return; fails with TypeError … Return value must be of type int, null returned. Of the three applications on the framework, one had such a command, and it has been fixed in that application. Grep for return; inside execute().

Documentation

Fixed in passing

  • ClientPoolTest measures with hrtime(). A wall clock stepped backwards once measured a serialised batch as -0.2 s.

The framework's suite runs in parallel: ./dockertest --parallel

ParaTest over four workers, then the serial group in a single process. It takes about 2 minutes, against 5:08 sequentially. The sequential run remains the reference, and the only one with coverage.

Added

  • brianium/paratest ^7.8 as a dev dependency. It installs now that symfony/console is on 6.4.
  • ./dockertest --parallel[=N]. Its exit status is red if either half is.
  • A database per worker. tests/bootstrap.php defines TEST_DATABASE: pramnos_test, or pramnos_test_<N> under ParaTest. It creates that database on both servers and points DB_NAME / PG_NAME at it. The 154 literal 'pramnos_test' in the tests now read the constant.
  • tests/Support/DatabaseStateIsolation. Each test class starts with no settings and no connection, as the first class of a sequential run does.

Fixed

What the sequential order had been hiding. In parallel, each worker runs a different subset in a different order, and about 200 tests failed at first: - 16 test files used a helper class declared in another test file, under a name the autoloader cannot map. They now require_once it. - Three messaging tests loaded settings from 'settings', which is not a path. They worked only after a class that loaded real ones. - AdminerNotFoundTest assumed an application was current, made by whichever test ran before it. - UsersControllerTest's activity test counted rows that earlier runs had left for its user. - AccountSsoTest's harness overrode currentUserId() but not currentUser(), so sso() reached the real database. - A test with 'pramnos_test' inside a SQL string.

About 40 classes are in #[Group('serial')], for what a database per worker does not separate: files under the project, Redis, /tmp, schemas left behind by earlier classes, and timing that four workers' load disturbs. In repeated runs, one or two tests in the parallel half still fail now and then, a different test each time. Treat a parallel-only failure as a test that shares something.

Later the same day: - Each worker has its own var/. tests/bootstrap.php defines VAR_PATH and CACHE_PATH as var/workers/<N>, so two workers no longer share the file cache, the verified-migration markers or the spool. - MediaRenditionPolicyTest writes to www/uploads/media_policy_<N>. - The cache and media classes have left the serial group, which is down to about 40 classes. - Three more serial runs, three parallel runs green. - WorkLoopTest measures with hrtime(). The wall clock, stepping back, measured a 1 s sleep as 0.08 s.

Documentation

Flaky tests: the clock and one-second margins

A search for tests that fail now and then, found by running the full suite four times in a row and sweeping for the patterns behind the failures. Every one had one of two causes.

Fixed

  • Measuring with the wall clock. On a virtual machine the wall clock is corrected in steps, sometimes backwards. Four more tests now measure elapsed time with hrtime(): FileAdapterGarbageCollectionTest, TreeTest, InitDockertestTimerTest and WebSocketClientTest. RedisStreamDriverTest waits until the driver's own clock has passed its deadline, instead of sleep(1), and leaves the serial group.
  • One second of margin. AddressRateLimitTest aged a failure to 599 s of a 600 s window, so a second ticking before the next attempt put it outside. It is now 590 s. Four tests that set something "already expired" at time() - 1 now use time() - 60: password reset, new-device link, queue retry delay and token introspection. A clock stepped back by more than a second had made those not yet expired.
  • The init spinner measures with a monotonic clock. RunsProcesses timed a step with microtime(), so after a step back the elapsed time read (0s) throughout a slow step, and it never switched to live output. This was a framework fix, found by InitSpinnerTest failing once in the second hunt.
  • Shared paths. Eleven more classes are in the serial group, because they write or delete files under the project root that another class also touches. Examples are src/Models/*.php, src/Api/routes.php, frontend/, app/language and ROOT/var. Two had already collided. The rest were found by sweeping for writes under ROOT in classes outside the group.

The second and third hunts ran five sequential and six parallel runs in all. The only failure in the third was the spinner.

The suite passes on a database built from nothing

The sequential run had only ever been checked against a test database kept for months. To check it against an empty one, --parallel now drops the workers' databases first. On empty databases 227 tests failed, all for one reason: a table that another class happened to create first, with a shape of its own.

Fixed

  • Hand-rolled framework tables. OauthTest, OauthCoverageTest, ScopesMySQLIntegrationTest, TokenCharacterizationTest and OAuth2GrantFlowMySQLTest created users, usertokens or applications with CREATE TABLE IF NOT EXISTS and a shape of their own. The first such class to reach an empty database defined the table for every class after it. One defined email as NOT NULL with no default, and that was 225 of the failures. These classes now build the tables through Testing\Schema::table(), from the migrations, as the Testing Guide prescribes. ForeignKeyGuardMigrationTest and TokenActionPostgreSQLTest had stub users tables of one or two columns, and now do the same.
  • The core table the push log needs. DatabaseChannelMySQLTest, TestPushTest and SubscriptionsTest ran the notifications migrations without CreateFrameworkPoliciesTable, which the push log registers its retention in.
  • ./dockertest --parallel starts from nothing. The workers' databases and var/workers are dropped before every run, so no run inherits what an earlier one left.

Verified by dropping pramnos_test on both servers and running the whole suite sequentially: 18,210 tests, green.

Tests that passed or failed by the order they ran in

Running the suite in random order (--order-by=random), on databases dropped first, showed which tests depended on the classes that happened to run before them. Three seeds started at over twenty failures each.

Fixed

  • A ledger made by the legacy migration path. Application::runMigration() writes only key, so an installation that came from it can have a schemaversion with no when, no extra and no unique index. The runner added every newer column except those two, and its first insert names both. The first migrate on such an installation failed with "Unknown column 'extra'". MigrationRunner now adds both columns, and a unique index on key when there is none. PostgreSQL's ON CONFLICT needs that index. On MySQL, a ledger whose keys already repeat is left without the index and keeps working.
  • Database built from a flat configuration. In the older flat shape database is just the name, and the constructor read ->hostname, ->user and the rest off that string: one warning per key, and a connection with every field empty. A database setting that is not a connection block now configures nothing, the same as no block.
  • Validator::getInstance() answered with whichever class asked first. A method's static variable is shared with subclasses since PHP 8.1. So after Validation\Validator::getInstance(), the deprecated General\Validator::getInstance() handed back an instance of the new class, and the other way round. Each class now has its own instance.
  • What the framework remembers for the life of a process. Token remembers whether usertokens has a lookup column. The second-factor registry, Gate, the health checks, the mail types and some twenty other registries keep what was registered into them. The Auth singleton keeps its drivers and afterLogin() callbacks; one test's callback asserted inside another class's sign-in. Permissions keeps the store it found, and $_SESSION kept a uid that made later classes load that user. The user cache, the request object, $_GET/$_POST and every application instance are reset as well. A constructor-less application double left in as default was the site for every later class. A class that rebuilt a table or registered a fake left that state for whichever class came next. The suite's isolation extension now resets all of it at the start of every class. Token::forgetSchemaCache() is new for this.
  • A test that changes a table's shape says so. DatabaseTestCase::schemaChanged() makes the next setUp() rebuild the owned tables. Without it, the tests after the change inherited it.
  • Testing\Schema builds tables the way the migrations do. usertokens and applications now include their later columns, and authserver.permissions has a recipe. Tables a recipe depends on, users and applications under usertokens, are built first. SchemaRecipesAreCompleteTest fails when a migration adds a column to a table that has a recipe and the recipe does not run it.
  • Some forty test classes created framework tables with a shape of their own, assumed a table an earlier class had made, or left a cookie, a session, a debug bar, a clock leeway, a stored widget or a cached user row behind. They now build what they use and clean up what they set. AdminAccessTest builds the application before it arranges an administration area. Building it later, inside the test, ran the constructor's AdminArea::reset() and undid the arrangement.
  • Lockout deadlines read one second long. remaining is the stored deadline minus time(). On a host whose clock is corrected backwards, which WSL does routinely, a lockout of exactly N seconds read as N + 1. The tests allow that one second, and say why.

Verified on databases dropped before each run, in random order, with nine seeds: 101, 202, 303, 404, 505, 606, 707, 808 and 909. All 18,216 tests passed in each run, and the sequential and --parallel runs passed as well.

Documentation

  • Migration Guide: "A ledger an application made itself".
  • Testing Guide: schemaChanged() in the DatabaseTestCase section.