Skip to content

14 September 2026

4 changes:

  • Saving a setting was check-then-act
  • A migration test left the settings table dropped for everything after it
  • A second parameter added a week ago was a fatal for anyone overriding the method
  • Every signature that moved since v1.2 now has a row in the Upgrade Guide

Saving a setting was check-then-act

Settings::setSetting() asked whether the row existed and then inserted or updated it:

$exists = …->where('setting', $setting)->exists();   // ①
if ($exists) { …update(['value' => $value]); }       // ②
else         { …insert(…); }

Two administrators saving settings in the same second both see "no row" between ① and ②, both insert, and the second gets a duplicate-key error out of a screen that had every reason to work. Nobody has reported it, and nobody would recognise it if they had — it needs two saves inside one second and it looks like a fluke.

Fixed

  • One statement, ON CONFLICT (setting) DO UPDATE on PostgreSQL and ON DUPLICATE KEY UPDATE on MySQL, through QueryBuilder::upsert(). Both saves land; the later value wins, which is what the two administrators would have expected of an interface with no locking in it.

The constraint that makes this possible was already there and unused. 2026_05_26_000051's own docblock says "Settings::setSetting() relies on row-level uniqueness" — the index was added for this race and the code kept racing.

Why there is still a fallback

That migration declines on a table that already holds two rows for one name: deleting somebody's configuration is not a migration's decision. So an installation can be fully migrated and still have no uq_settings_name — and the upsert is not safe there. PostgreSQL raises «no unique or exclusion constraint matching the ON CONFLICT specification»; MySQL does something worse, inserting a further duplicate in silence, compounding the problem the constraint exists to prevent.

So the write path is chosen by asking the catalogue whether the index is there, and fails closed — a catalogue that cannot be read answers "no" and takes the path the framework has had for its whole life. The way out of the fallback is to resolve the duplicates and run migrations, not to change this code:

SELECT setting, COUNT(*) FROM settings GROUP BY setting HAVING COUNT(*) > 1;

The answer is memoised per process, because a settings form calls setSetting() once per field and a hundred catalogue queries per save is not a trade anyone wants. That leaves one case where a process can hold a stale answer — a migration applying underneath a long-lived worker — and Settings::forgetSchemaFacts() is the way to clear it.

A class property rather than a static inside the method, for the mundane reason that a function static cannot be reset: memoising there would have made the memo impossible to test and forgetSchemaFacts() impossible to write.

Not a behaviour change for anyone with one writer

Same rows, same values, same cache invalidation. The difference is only visible when two writes overlap, which is the entire point.

Two things the test got wrong first

A guard that could not guard. The class checks that settings is usable before it runs. It checked by issuing a DELETE through the query builder and treating an exception as the signal — and got none, because every builder terminator runs through Database::execute(), which records the error and returns false. Only Database::query() raises, so a DELETE against a table that is not there answers exactly like one that matched no rows. Worth knowing outside this class: a builder call that returns cleanly is not evidence the table exists. The probe is a raw SELECT 1 … LIMIT 1, the one place in the class where the builder is not used, because the builder cannot express fail if this is not there.

And then it skipped instead of running. With the probe telling the truth, all twenty tests skipped in the full suite while passing under --filter: by the time they run, public.settings is gone. A skip is not a pass. Twenty green-looking skips are a class that proves nothing about the code it was written for, and it would have stayed that way — the suite exits 0 either way, and the count is one line of summary nobody reads twice.

So the table is arranged rather than assumed: if it is not usable, the class runs the framework's own CreateSettingsTable migration and carries on. That direction is safe in a way the opposite is not — creating a table the rest of the run expects cannot break a later test, while dropping one demonstrably can — and it is what the suite already does elsewhere, in UnsubscribeFailurePathsTest. down() before up(), because the migration's early return asks hasTable(), which is the question that already answered wrongly once here.

A migration test left the settings table dropped for everything after it

FrameworkMigrations{MySQL,PostgreSQL}Test::testCoreSettingsUpCreatesTableWithDeleteDefaultOne ends by asserting that down() removes the table — correct, and then it walks away. But settings is not that test's table: it is the one Pramnos\Application\Settings writes to, so every later test in the run that touches a setting is running against a database that no longer has it.

A test class that writes settings landed after it in the run and produced one error and six failures saying relation "settings" does not exist, while passing on its own. A failure like that says nothing about the class it lands in, and the first instinct is to distrust the new code rather than the run order.

Fixed

  • Both migration tests put the table back with $m->up() after asserting the rollback. The assertion about down() is unchanged; the suite stops depending on test order.

A second parameter added a week ago was a fatal for anyone overriding the method

Found by auditing every public signature that moved since the tag, which is worth doing after a run of changes rather than once at release.

DevPanelController::returnUrlFor() gained array $alsoExclude = [] on 7 September, so the debug-toolbar grant screen could exclude itself from "where Back goes". Correct feature, wrong shape: PHP refuses to load a subclass whose declaration no longer matches the parent, and an optional argument is still a declaration change. An application overriding it gets a fatal at class load — the whole site, before a route is matched, not the developer panel it appears to be about.

Fixed

  • returnUrlFor(string $mountPoint = '') is back to its old signature, and the exclusions live on a new returnUrlExcluding(string $mountPoint, array $alsoExclude) that it delegates to. DebugGrantController calls the new one. Nothing about the behaviour of either changes.

This is the framework's own rule, and the rule exists because this has happened twice before: add a new method instead. Optional parameters read as additive and are not.

One that stays, and now says so

Application::startMaintenance() took $origin on 5 September — the same class of break, with no compatible way to do it, and that day's post carries its What breaks in subclasses section. What it did not have was a row in Pramnos_Upgrade_Guide.md, which is the page somebody actually reads before a composer update.

Every signature that moved since v1.2 now has a row in the Upgrade Guide

Having found one by looking, it was worth looking at all of them. Extracting every public/protected function declaration from src/ at the 1.2 tag and at main and diffing the two lists takes seconds, and turned up nineteen methods whose declaration had moved since the tag. None of them had a row.

That is not nineteen bugs. Most are optional parameters on methods nobody overrides, and every one of them was the right change. The problem is the failure mode they share: nothing fails at the moment of the change. The application compiles, the framework's own 15,982 tests pass, and the fatal arrives later, on an installation that happens to override the method — as the whole site, at class load, before a route is matched.

Added to ## Since v1.2 — unreleased

Five rows, grouped by what a reader has to do rather than by subsystem:

Row Why it is there
Two-factor administration The only caller break in the set: TwoFactorAuthService::disable() and regenerateBackupCodes() gained a required string $password. Existing calls raise ArgumentCountError.
Flash message markup Not a signature at all — _printErrors() defaults to CSS class error instead of pramnosError. A theme styling the old name silently stops applying.
Methods that gained a parameter Twelve of them. Harmless to call, fatal to override.
Methods whose return type changed Five. The same break, and one that cannot be fixed by adding an argument.
Controller actions accept mixed The type widened, so callers are fine and a subclass still declaring string is narrowing — which is equally fatal.

The section also gains a short note on how the list was produced, so the next person cutting a tag can re-run it rather than reconstruct it from a month of posts. Constructors are exempt — PHP does not compare those — and a changed default value is not a signature change.

Documentation

  • Pramnos_Framework_Guide.md gains Runtime settings — the settings table under Application Configuration: the read/write API, the index the one-statement write needs, the query that tells you whether your installation can have it, and what the fallback means.
  • Pramnos_Upgrade_Guide.md gains the six rows above plus startMaintenance, and the note on reproducing the signature diff.