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
$thisis 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¶
- Change Feed Guide → The event.
- Authorization Guide: what deleting a role removes.
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¶
- Authentication Guide → Erasure: what the framework deletes, as a table.
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 anyuserscolumn given, hashes a plainpasswordthe 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 throughAccountErasure. That covers the framework's tables, and the application's rows through itsaccount.data_eraselisteners. 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¶
- Testing Guide → Setup & Teardown. The
example there used to empty the
userstable intearDown().
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.
BaseTestCasenotes the highest key in each$watchedTablestable when a class starts.usersis 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 throughAccountErasure, other rows byDELETE. When the suite ends, it prints which classes left what on STDERR. A class whose ownsetUp()does not chain callswatchForRowsLeftBehind(). - A rebuild above a limit.
'test_database' => ['rebuild_above' => [table => rows]]in the test settings. Before the run, a table over its limit makesTestEnvironmentdrop 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¶
- Testing Guide → Rows a test class leaves behind, and Rebuilding an overgrown test database.
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 ofFullAuthorizationCodeFlowTest. The framework's own JWT middleware have always allowed 60 seconds, and so does this now, throughOAuth2ServerFactory::CLOCK_LEEWAY_SECONDS. - Tests that measured time with the wall clock:
DeadlockRetryTestmeasures withhrtime(). A 600 ms back-off had once measured as 211 ms.StructuredOperationParityTestwaits untiltime()itself has moved past the TTL.DatabaseDriverTestwaits on the clock the driver reads, instead of onsleep(1).TrackingInfoPostgreSQLTestclears$_SESSION['uid']as well as the$_SERVERkeys. A user signed in by an earlier test turnedReportingintoReporting_u4601.
Documentation¶
- Authentication Guide → Validating JWT tokens: the OAuth2 resource server's allowance.
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,OauthTestandTokensControllerTestrebuilt 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,OrganizationScopedChecksTestandAccountErasureTestcreated 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"inphpunit.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=offandfull_page_writes=off. These match the MySQL container's existingmy.cnf. The container keeps no volume. The PostgreSQL-heavy classes: 26 s → 21 s.
Fixed¶
FrameworkMigrationsPostgreSQLTestwroteapplications.appid = 1explicitly into the shared test database and left it there. The sequence stayed behind, and the next ordinary insert, inApplicationServicePostgreSQLTest, 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¶
- Testing Guide → Writing a test that does not slow the suite down:
the throwaway-database row, the explicit-id trap and
resolveDependencies.
Three more slow test classes: 303 s down to 288¶
Changed¶
MassMessageDispatcherTestdrops 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.GroupsScreenTestusesReusesProbeDatabase: 7.3 s → about 1 s. The test that drops the group tables calls the trait's newprobeChanged(), so the next test rebuilds them.RbacFunctionsCharacterizationTestinstalls the RBAC schema once per class and truncates its tables per test: 4.9 s → 0.8 s.ReusesProbeDatabaseempties PostgreSQL tables withoutRESTART IDENTITY. Like MySQL'sDELETE, 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, andcomposer updatetakes 6.4.ServeandMigrateLogsdeclareexecute(): intandconfigure(): 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¶
- Console Guide: the custom-command example declares its types and says why.
- Upgrade Guide: a new row.
Fixed in passing¶
ClientPoolTestmeasures withhrtime(). 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.8as a dev dependency. It installs now thatsymfony/consoleis on 6.4../dockertest --parallel[=N]. Its exit status is red if either half is.- A database per worker.
tests/bootstrap.phpdefinesTEST_DATABASE:pramnos_test, orpramnos_test_<N>under ParaTest. It creates that database on both servers and pointsDB_NAME/PG_NAMEat 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,InitDockertestTimerTestandWebSocketClientTest.RedisStreamDriverTestwaits until the driver's own clock has passed its deadline, instead ofsleep(1), and leaves the serial group. - One second of margin.
AddressRateLimitTestaged 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" attime() - 1now usetime() - 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
initspinner measures with a monotonic clock.RunsProcessestimed a step withmicrotime(), 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 byInitSpinnerTestfailing 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/languageandROOT/var. Two had already collided. The rest were found by sweeping for writes underROOTin 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,TokenCharacterizationTestandOAuth2GrantFlowMySQLTestcreatedusers,usertokensorapplicationswithCREATE TABLE IF NOT EXISTSand a shape of their own. The first such class to reach an empty database defined the table for every class after it. One definedemailasNOT NULLwith no default, and that was 225 of the failures. These classes now build the tables throughTesting\Schema::table(), from the migrations, as the Testing Guide prescribes.ForeignKeyGuardMigrationTestandTokenActionPostgreSQLTesthad stubuserstables of one or two columns, and now do the same. - The core table the push log needs.
DatabaseChannelMySQLTest,TestPushTestandSubscriptionsTestran the notifications migrations withoutCreateFrameworkPoliciesTable, which the push log registers its retention in. ./dockertest --parallelstarts from nothing. The workers' databases andvar/workersare 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 onlykey, so an installation that came from it can have aschemaversionwith nowhen, noextraand no unique index. The runner added every newer column except those two, and its first insert names both. The firstmigrateon such an installation failed with "Unknown column 'extra'".MigrationRunnernow adds both columns, and a unique index onkeywhen there is none. PostgreSQL'sON CONFLICTneeds that index. On MySQL, a ledger whose keys already repeat is left without the index and keeps working. Databasebuilt from a flat configuration. In the older flat shapedatabaseis just the name, and the constructor read->hostname,->userand the rest off that string: one warning per key, and a connection with every field empty. Adatabasesetting 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 afterValidation\Validator::getInstance(), the deprecatedGeneral\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.
Tokenremembers whetherusertokenshas 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. TheAuthsingleton keeps its drivers andafterLogin()callbacks; one test's callback asserted inside another class's sign-in.Permissionskeeps the store it found, and$_SESSIONkept auidthat made later classes load that user. The user cache, the request object,$_GET/$_POSTand every application instance are reset as well. A constructor-less application double left in asdefaultwas 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 nextsetUp()rebuild the owned tables. Without it, the tests after the change inherited it. Testing\Schemabuilds tables the way the migrations do.usertokensandapplicationsnow include their later columns, andauthserver.permissionshas a recipe. Tables a recipe depends on,usersandapplicationsunderusertokens, are built first.SchemaRecipesAreCompleteTestfails 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.
AdminAccessTestbuilds the application before it arranges an administration area. Building it later, inside the test, ran the constructor'sAdminArea::reset()and undid the arrangement. - Lockout deadlines read one second long.
remainingis the stored deadline minustime(). 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 theDatabaseTestCasesection.