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_numberclaim 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.usertokensnow also buildsusersandapplicationsfirst, 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.AccountChangePasswordTestremoves any row for the process's own session before it records it. The session id belongs to the process, so under--parallelan 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
tmpfsat/var/lib/mysqlmeansCREATE,DROPandALTERno 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, becausefsync=offalready 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 unguardeddefine(),header()orexit. 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
sessionsrecipe.DashboardControllerTestbuilt its ownsessionskeyed onsid. 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
.invalidhostname and waited 4–8 s for DNS to give up. They now connect to a closed port on127.0.0.1, which refuses at once. Two linting tests ran onephp -lper file and now run one for many files.OAuth2GrantFlowMySQLTestandTwoFactorAuthServicePostgreSQLTestbuild their tables once per class instead of once per test.FrameworkMigrationsMySQLTestdrops only the objects that exist, where it had issued about 120DROP … IF EXISTSstatements twice per test. - Cookies are reset before every test class, together with the session and the request inputs.
MediaObjectTestempties themedialisting 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 onephp -lper 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_numberusesphonewhenmobileis empty, and isnullwhen both are empty.OauthCoverageTestno longer patches the framework tables after building them from their migrations. ItsALTER TABLEblock dated from hand-built copies, and it reshaped the real tables:users.mobilenullable,maingroupadded. The one test that readsmaingroup, 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_numberand treat''as "no number". - AuthServer Integration Guide: where
phone_numbercomes 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, aftertests/bootstrap.php, and puts it back before every test class: settings, the default connection, the applications,Auth,Permissions, the registries,$_SESSION,$_GET,$_POST,$_REQUEST,$_COOKIEand$_ENV, and the environment thatputenv()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 initregisters it inphpunit.xml, and the framework's own suite now uses it too, with only the loading of its fixture settings left intests/Support.
Fixed¶
ApplicationOwnUserClassTestbuildsusersanduserdetails. 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 generateddocker-compose.ymlsays, 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=randomand the causes that have turned up with what to do about each. AlsoProcessStateIsolationin 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
ProcessStateIsolationandServerGlobalIsolation.
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 byApplication's constructor. When a fatal error ends the process, it writesfatal.logwith 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: setornot 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 andModel'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_argsoff. Productionphp.inifiles usually set it on. Checkphp -i | grep exception_ignore_args; if it was off, search the framework log forexecuteCommandand 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'sDkimSigner, 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 thesmtp_*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()andsetAuthMode(). 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 = [])setsIn-Reply-To, and setsReferencesto the original's references followed by the original.Email::withMessageId($id)sends a message with an id the application chose and stored.
Fixed¶
addHeader()writesMessage-ID,In-Reply-ToandReferencesas message ids, in any letter case and with or without angle brackets. An id that is notlocal@domainis 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'sextrainfocolumn, as JSON, andmail:flushrestores it before sending. No column is added.extrainfois 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.
MessageReportshowsextrainfoas the error only on a failed message, and no longer on a queued one.- New public methods:
Email::outboxState()andEmail::restoreOutboxState().
Documentation¶
- Email Guide: "What a queued message keeps", and the threading section.