14 September 2026¶
4 changes:
- Saving a setting was check-then-act
- A migration test left the
settingstable 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 UPDATEon PostgreSQL andON DUPLICATE KEY UPDATEon MySQL, throughQueryBuilder::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:
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 aboutdown()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 newreturnUrlExcluding(string $mountPoint, array $alsoExclude)that it delegates to.DebugGrantControllercalls 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.mdgains Runtime settings — thesettingstable 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.mdgains the six rows above plusstartMaintenance, and the note on reproducing the signature diff.