Skip to content

6 October 2026

10 changes:

  • The suite's speed is back: 394 s of tests down to 303
  • The suite: 5:24 down to 3:30 sequentially, and 1:25 in parallel
  • The OIDC phone_number claim falls back to the phone when there is no mobile
  • Projects get the suite's process isolation, and a scaffolded MySQL no longer logs every statement
  • A fatal error says what was running: fatal.log
  • Email's debug log no longer writes the start of the SMTP password
  • A logged stack trace no longer carries its calls' arguments
  • Mail from a customer's own domain: optional DKIM signing and a per-message server
  • A reply can go into the thread of the message it answers
  • A queued message keeps everything it was queued with

The suite's speed is back: 394 s of tests down to 303

Making the suite independent of test order cost 92 seconds of test time. The sequential run went from 5:22 to 6:59. Comparing the time of every class between the two versions put nearly all of it in classes that build their tables with Testing\Schema::table() from setUp(). AdminAccessTest went from 2.3 s to 11.3 s.

Fixed

  • Testing\Schema::table() builds a table once per process. It ran every migration in the recipe on every call, and each migration asked the catalogue whether its table and columns exist. usertokens now also builds users and applications first, which made a call about 280 ms on MySQL. The first call now records the table's columns, and later calls compare them in one query, rebuilding only a table that was dropped or reshaped.
  • AccountChangePasswordTest removes any row for the process's own session before it records it. The session id belongs to the process, so under --parallel an earlier class in the same worker could have recorded the same session for another user.

Measured on databases dropped before each run: 303 s of tests and 5:24 wall time sequentially, against 302 s and 5:22 before the isolation work. The parallel run takes 1:33 for the workers and 0:44 for the serial group.

Documentation

  • Testing Guide: what a repeated Schema::table() call costs, and what it does not notice.

The suite: 5:24 down to 3:30 sequentially, and 1:25 in parallel

Test time went from 303 s to 198 s. The sequential run went from 5:24 to about 3:30, and the parallel run from about 2:44 to 1:25.

Fixed

  • The MySQL test container keeps its data in memory. A tmpfs at /var/lib/mysql means CREATE, DROP and ALTER no longer go through Docker's virtualised disk. That disk is slow on WSL and Docker Desktop. The MySQL migration tests went from 9.9 s to 3.2 s. PostgreSQL gained nothing measurable from the same change, because fsync=off already keeps it off the disk, so it stays as it was. Recreating the containers picks this up: docker-compose up -d db.
  • Eighteen database test classes no longer run each test in its own process. #[RunTestsInSeparateProcesses] was there to keep a MySQL connection from reaching a PostgreSQL class. The isolation extension now does that by dropping the connection before every class, so a PHP process and a bootstrap per test bought nothing. The attribute stays where a process is what isolates the test: an unguarded define(), header() or exit.
  • Testing\Schema::table() notices more than columns. It compares a table's whole shape: column types and nullability, indexes, constraints and triggers. A recipe table that a test changed after it was built is dropped and built again. Before, only columns were compared, and running the recipe over a changed table could not restore a dropped index, because create migrations return early on a table that exists.
  • A sessions recipe. DashboardControllerTest built its own sessions keyed on sid. The session id is the same for every request in a process, so in any run where that class went first, a session tracking test updated one row instead of recording a visit. Both classes now build the table from its migrations.
  • Tests that waited on nothing. Two WebSocket tests connected to a .invalid hostname and waited 4–8 s for DNS to give up. They now connect to a closed port on 127.0.0.1, which refuses at once. Two linting tests ran one php -l per file and now run one for many files. OAuth2GrantFlowMySQLTest and TwoFactorAuthServicePostgreSQLTest build their tables once per class instead of once per test. FrameworkMigrationsMySQLTest drops only the objects that exist, where it had issued about 120 DROP … IF EXISTS statements twice per test.
  • Cookies are reset before every test class, together with the session and the request inputs.
  • MediaObjectTest empties the media listing cache with its rows. getList() keeps a listing for sixty seconds. A test that expected an empty list was given the rows an earlier test had inserted and listed.

Documentation

  • Testing Guide: the shape Schema::table() compares, the MySQL container in memory, and two rows in the table of what slows a suite down: separate processes, and one php -l per file.

The OIDC phone_number claim falls back to the phone when there is no mobile

UserClaims filled phone_number with $u['mobile'] ?? $u['phone']. In the shipped schema both columns default to '', not null, so for a user with no mobile the claim was always '', even when a phone number was on file. It is now the mobile, else the phone, else null, which is how OIDC says a claim has no value.

The test that should have caught this had made users.mobile nullable with an ALTER TABLE and then set it to NULL. That was a shape no installation has.

Fixed

  • phone_number uses phone when mobile is empty, and is null when both are empty.
  • OauthCoverageTest no longer patches the framework tables after building them from their migrations. Its ALTER TABLE block dated from hand-built copies, and it reshaped the real tables: users.mobile nullable, maingroup added. The one test that reads maingroup, which is an application's column and not the framework's, adds it for its own duration.

Documentation

  • Upgrade Guide: a row for clients that read phone_number and treat '' as "no number".
  • AuthServer Integration Guide: where phone_number comes from.

Projects get the suite's process isolation, and a scaffolded MySQL no longer logs every statement

What made the framework's own suite pass in any order lived in its tests/ directory, which is not shipped. A project kept the failures it would have prevented.

Added

  • Pramnos\Framework\Testing\ProcessStateIsolation, a PHPUnit extension. It records the process once, after tests/bootstrap.php, and puts it back before every test class: settings, the default connection, the applications, Auth, Permissions, the registries, $_SESSION, $_GET, $_POST, $_REQUEST, $_COOKIE and $_ENV, and the environment that putenv() changes. It empties the caches of table shapes and loaded users. It restores rather than empties because a project's bootstrap sets things up that every class needs, such as settings, an initialised application, or a driver it registered. What the bootstrap set up stays, and what a class added is gone. pramnos init registers it in phpunit.xml, and the framework's own suite now uses it too, with only the loading of its fixture settings left in tests/Support.

Fixed

  • ApplicationOwnUserClassTest builds users and userdetails. It read them and had found them only because a class before it had built them.

Changed

  • A scaffolded MySQL server no longer runs with --general-log=1. That option wrote every statement to a file that nothing rotated, including the tens of thousands a test run issues. The generated docker-compose.yml says, in a comment, how to turn it on for a debugging session.

Documentation

  • Testing Guide: "A test that passes alone and fails in the suite", covering how to find one with --order-by=random and the causes that have turned up with what to do about each. Also ProcessStateIsolation in the isolation table, and the general log in the table of what slows a suite down.
  • Upgrade Guide: the extension list for existing projects now includes ProcessStateIsolation and ServerGlobalIsolation.

A fatal error says what was running: fatal.log

A production server logged Allowed memory size of 3221225472 bytes exhausted in Model.php. That was where the last allocation fell, and nothing said which command had loaded the rows.

Added

  • Pramnos\Logs\FatalErrorReporter. It is registered once per process by Application's constructor. When a fatal error ends the process, it writes fatal.log with the command line or the request's method and path, the request id, peak memory against the limit, the elapsed time and the pid. Secret-looking option values and query strings are left out. It holds back 256 KB, releases them at shutdown and raises the limit before writing, so an out-of-memory process still leaves its report. A test proves that with a real process that runs out of memory.

Documentation

  • Logging Guide: "When the process dies: fatal.log".

Email's debug log no longer writes the start of the SMTP password

With setDebug(true), Email::send() logged a credentials check: host, user, port, the password's length, and the first four characters of the password. That is the log operators paste into tickets. Reported by a consuming project.

Fixed

  • The check logs SMTP Password: set or not set, and nothing else about the password. Rotating a password that was sent with debug on is worth considering: four characters, the length and the account narrow a guess considerably.

Documentation

  • Email Guide: what the debug log contains.

A logged stack trace no longer carries its calls' arguments

Found while fixing the entry above. Every SMTP failure, debug mode or not, logged $e->getTraceAsString(). With PHP's default zend.exception_ignore_args = Off, that writes up to fifteen characters of each string argument. Symfony Mailer sends the password as executeCommand(base64(password)), so when a server rejected the password, about eleven of its characters went into the log. The same pattern was in five other places.

Fixed

  • Pramnos\Logs\Trace::of($throwable): the trace in the same layout, with no arguments. Email (two places), Api's SQL error log, ExceptionHandler (the log line, the debug JSON and the debug HTML page), Logger's exception context and Model's captured change origin now use it.
  • Where the password may have reached a log: an installation whose SMTP server ever rejected a login, with zend.exception_ignore_args off. Production php.ini files usually set it on. Check php -i | grep exception_ignore_args; if it was off, search the framework log for executeCommand and rotate the password if it appears.

Documentation

  • Logging Guide: "Stack traces are written without arguments".

Mail from a customer's own domain: optional DKIM signing and a per-message server

A consuming project will reply to press releases from a customer organisation's own address. For DMARC to pass, that message needs a DKIM signature for the customer's domain, and sometimes it has to leave through the customer's own server. Both are opt-in per message. Without them nothing changes, and the installation's mail server goes on signing for its own domain.

Added

  • Email::signWith($domain, $selector, $privateKeyPem). The message is signed with Symfony Mailer's DkimSigner, after every other header.
  • Pramnos\Email\DkimKey::generate($domain, $selector). It returns the key pair, plus the TXT record name and value to publish. DnsAuthentication::inspect() reports whether the record has been published.
  • Email::via([...]). It sends one message through an SMTP server of its own, with the same five keys as the smtp_* settings. withTransport() does the same for any Symfony Mailer transport.
  • queue() sends such a message at once. The outbox would otherwise have to store the private key or the password in a row.

Changed

  • A failed SMTP send names its server: SMTP host:port: …. This matters once a message can choose its server. It is in the Upgrade Guide.
  • Two calls behind method_exists() are gone from the transport setup: setStartTLS() and setAuthMode(). Neither method exists in symfony/mailer, so they never ran. The ESMTP transport upgrades with STARTTLS on its own.

Documentation

  • Email Guide: "Mail from a customer's own domain: its signature and its server".
  • Upgrade Guide: the SMTP error text.

A reply can go into the thread of the message it answers

Reported by a consuming project that replies to press releases. addHeader() added every header as text. Symfony requires In-Reply-To and References to be identification headers, so the send threw a LogicException from inside the transport. Nothing recorded the Message-ID a message went out with, so there was nothing to reply to either.

Added

  • Email::inReplyTo($messageId, $references = []) sets In-Reply-To, and sets References to the original's references followed by the original.
  • Email::withMessageId($id) sends a message with an id the application chose and stored.

Fixed

  • addHeader() writes Message-ID, In-Reply-To and References as message ids, in any letter case and with or without angle brackets. An id that is not local@domain is refused when it is added, with the header named.
  • A threaded message given to queue() is sent at once. The outbox keeps no headers, so it would have arrived outside its thread.

Documentation

  • Email Guide: "A reply in the same thread", under the headers a message carries.

A queued message keeps everything it was queued with

Found while adding threading. mail:flush rebuilt each queued message from its mails row: the address, the subject and the composed body. Everything else was dropped between queue() and the send:

  • copies and reply-to;
  • the attachment;
  • addHeader() headers;
  • the unsubscribe state, so queued list mail went out without List-Unsubscribe, which Gmail requires of bulk senders;
  • tracking.

Fixed

  • Email::queue() keeps that state in the row's extrainfo column, as JSON, and mail:flush restores it before sending. No column is added. extrainfo is unused while a row is queued, and its final state ('' once sent, or the error) replaces the kept state. A row queued before this change has none and sends as it did.
  • A threaded message is queued normally. The entry above sent it at once because the outbox kept no headers.
  • MessageReport shows extrainfo as the error only on a failed message, and no longer on a queued one.
  • New public methods: Email::outboxState() and Email::restoreOutboxState().

Documentation

  • Email Guide: "What a queued message keeps", and the threading section.