28 September 2026¶
5 changes:
- Two of the three paths that run a migration left the column cache stale
- …and the cache they flush was keyed by table only, so two databases answered for each other
BotDetectorcan be widened to about 1,500 crawlers, for anybody counting visits- The migration ledger records the version it reached, so an application can report it
- The auto-run fingerprint could not see a migration that was not timestamped
Two of the three paths that run a migration left the column cache stale¶
getColumns() caches a table's schema for an hour. MigrationRunner::run() has flushed it
since a raw ALTER TABLE … ADD COLUMN left every reader an hour behind — but it is not the
only way a migration runs, and the other two had nothing.
rollback()¶
run() flushed and its mirror did not. A down() that drops a column is the more
confusing direction: every reader goes on believing the column is there, so code selects one
the database no longer has and the failure is a query error a long way from the rollback.
It became reachable in more cases two days ago, when the runner started emptying the
addQuery() queue on that path too — before that, a down() written against the queue
changed nothing at all.
Application::runMigration() — and it had the other bug too¶
The legacy loader, which upgrade() uses and which exec() reaches on its own when the
version check fails. It had neither of the two things MigrationRunner learned:
addQuery()queued and nothing emptied the queue. A legacy migration that queued its statements and returned was recorded inschemaversionand changed nothing — precisely the defect fixed two days ago, in the caller nobody looked at. Two callers, one fix, and only one of them had it.- No flush, so the column a migration added stayed invisible for an hour.
The flush is in a finally rather than after the insert, because a migration that raised
half way is the worst of the three outcomes: some of its DDL ran, the ledger has no row,
and the cache is now wrong about a table nobody knows changed. There is a test for exactly
that case.
What is still on you¶
A schema changed by hand outside migrate, and — on more than one node — the other
machines, whose caches none of this can reach. cache:clear after migrate in a deploy
script was never wrong to.
Documentation¶
- Migration Guide — a table of which path flushes when, why the rollback matters for the opposite reason to the rest, and that emptying the queue now holds on both paths.
…and the cache they flush was keyed by table only, so two databases answered for each other¶
schema_columns_<table>, with the prefix resolved into the key and the database not. So
two databases behind one cache — an installation's test and dev, or two installations on one
Redis — shared a column list per table name.
The symptom is the quiet kind. A model saved against a test database silently dropped three
columns a migration had just added: a command run against the dev database on the same
Redis, with that migration not yet applied, had rewritten schema_columns_properties with
its older list, and Model::_save() used it. The columns read back as null and nothing
refused anything.
Test and dev is the common case and the worst one, because the two are meant to differ while a migration is being written — which is exactly when somebody is reading the result and drawing conclusions from it.
The same shape as the half that was already fixed¶
That method's doc-block already tells this story about the prefix: the #PREFIX# marker
used to reach the cache unresolved, so alpha_users and beta_users shared one entry, and
"two installations with different prefixes shared one entry" was written down as the reason
to resolve it. The database was the other half of the same sentence and was left.
Fixed¶
columnCacheCategory()takes the connection, and the key isschema_columns_<database>_<schema>_<table>. The schema is in it because PostgreSQL holds two tables of one name in two schemas, which is whatauthserver.xrelies on.- Empty when the connection cannot say which database it is on. A hand-built connection or a test double has nothing to scope by, and inventing something would be worse than the bug: two callers that invented differently would stop sharing an entry they are entitled to.
- Entries under the old key become unreachable rather than wrong. They expire on their own, so the change is a one-time invalidation nobody has to run.
Two tests, because the key and the wiring fail differently¶
The unit test holds the format. The integration test creates the same table in two real databases with different columns, reads through two connections in both orders, and holds the wiring — because a key that is right while the caller passes nothing is the shape these failures keep taking, and it would pass every assertion about the format.
Both orders, because a cache bug is usually only wrong one way round: reading the shorter list first drops a column silently, reading the longer one first claims a column that does not exist and fails as a query error instead.
Documentation¶
- Migration Guide — "the key carries the connection", with what the failure looked like.
- Upgrade Guide — a row: nothing to run, but a subclass of
DatabaseoverridingcolumnCacheCategory()is a fatal at class load until its signature matches.
BotDetector can be widened to about 1,500 crawlers, for anybody counting visits¶
The class was extracted from the session addon so that "analytics" could use it, with about
thirty-five patterns. That is enough for what it was extracted for — keeping crawlers
out of the sessions table — and not enough to count anything: a counter that lets crawlers
through reports them as readers, and the symptom is a number slightly too high, which nobody
can see is wrong.
Measuring first changed the story¶
The obvious version of this finding is "thirty-five patterns misses the obvious crawlers".
It does not. One of them is a generic bot, so SemrushBot, AhrefsBot, PetalBot, Bytespider
and headless Chrome are already caught — and a data provider built on those would have
asserted nothing while looking thorough.
What it misses are the crawlers that do not say "bot": python-requests, Go-http-client,
okhttp, Scrapy, Zabbix, link unfurlers, Google-InspectionTool. Those are also the ones
most likely to be hammering a site. The provider is what was left after measuring, and the
doc-blocks say so, because "it misses the obvious ones" is the easy story and it is untrue.
Added¶
jaybizzle/crawler-detectinsuggest— one compiled regular expression over about 1,500 crawlers, and maintained, which is the part a hand-kept list cannot be.isBot()uses it when the class exists.- A
suggest, not arequire, on the same reasoning asmatomo/device-detectorbeside it and the push library: an application that never counts a visit should not carry a crawler list. Without it the class answers exactly as it always did. botName()consults the framework's own patterns first, because they carry a label somebody wrote —Googlebot— while the library reports only which fragment it matched on. The library is the wider net, not the better label.- It is in
require-devtoo, so this suite exercises the path an application gets by installing it. That leaves the absent path — the common one — unreachable, socrawlerDetectClass()is a seam and a test names a class that is not there. Both branches run for real; neither is excused.
Documentation¶
- Framework Guide — "telling a crawler from a reader": what the built-in list does and does not catch, measured, and how to widen it.
The migration ledger records the version it reached, so an application can report it¶
An installation had run its migrations 0148, 0149 and 0150. Its footer said 0.147, and so
did the DB_VERSION field of its API. Neither was lying about what it had been told: both
read applicationInfo['database_version'], a settings key somebody edits by hand, and the
three deploys that ran the migrations did not edit it.
The ledger knew the truth and could not say it. It records a slug —
2026_09_28_000001_add_thing, or migration0150 on the legacy numbering — which sorts by
the day the file was written and is not a number anything can print. The version each
migration declares was read once, by adoptLegacyVersions(), to match a row it was
adopting, and then dropped.
Added¶
schemaversion gains a version column, and recordHistory() writes $migration->version
into it on every path that records a migration — including adoption, so an installation
coming off the legacy ledger keeps the number it has been reporting for years. No migration
file: the ledger has to be readable before migrations can run, so it grows its own columns
through the add-missing-column lists ensureHistoryTable() has always had.
The answer is the highest version among the migrations recorded as successfully applied,
in the app scope unless another is named. MigrationRunner::latestVersion() is the same
answer without an Application.
What it excludes is the whole of the work, because each exclusion is a row in the same
table: a migration recorded as having run with errors is retried on the next run and so has
not been applied; the fingerprint row the request-lifecycle check writes is bookkeeping with
no version; and the comparison is version_compare() rather than ordering, because 0.100
is above 0.099 and every string ordering puts it below — at the hundredth migration, which
is a place an application arrives at once and does not expect to go backwards.
null when nothing versioned has run. Not '0': a wrong number in a footer reads like an
answer, and an empty one reads like what it is.
Both conventions count¶
The legacy path writes the version as the row's key and nothing else —
INSERT INTO schemaversion (key) VALUES ('0.147'). That is a record of a migration that
ran, and on an installation which has never run this runner it is the only record there is.
Reading the new column alone would answer null about a database that has recorded its
version on every upgrade for years.
So a row with no version is read through its key, and the answer is the highest of both
sets — not one falling back to the other, because a ledger mid-migration to the runner holds
both at once and the question is what version the database is on, not which convention
recorded it.
A key counts as a version only when it is digits and dots with at least one dot. That is on
characters, not on a guess about meaning: every slug this runner writes carries a letter or
an underscore (2026_09_28_000001_add_thing, migration0148, __fw_auto_3_…), so the two
sets cannot overlap, and a bare 2026 stays out for want of a dot.
The existing history is filled in, once¶
recordHistory() writes the version of a migration it is recording now, and a migration
that has already run is excluded from every later run by getPending(). So the column on
its own would have been empty for every row an installation already has — which is every row
that matters, on exactly the installations that reported the wrong number. The answer would
have been null until the next new migration happened to run.
adoptLegacyVersions() now also supplies the version of a slug the ledger already holds
without one. It runs before migrate decides anything is pending, so one
php pramnos migrate after upgrading is enough, with nothing pending. It writes one
column of a row that says the work was done; it does not do the work again.
The migration object is the only place the number exists — the row holds a slug, and nothing anywhere maps one to the other — which is why this happens where the objects are in hand rather than as a migration of the ledger itself.
What it deliberately does not do¶
The report asked, as an option, that applicationInfo['database_version'] be filled from
this at boot, so existing readers would keep working untouched. That key is the gate
checkversion() opens — and it only opens when the key is set. An installation that has
moved to the runner has it unset, which is what keeps the legacy upgrade() path switched
off. Filling it would switch that path back on for every one of them, which is the thing the
report itself asked to avoid.
So the value is available and not installed. An application that wants it in its settings array can assign it in one line, knowing it is asking for the legacy path too.
The auto-run fingerprint could not see a migration that was not timestamped¶
Two application migrations were deployed and were still pending weeks later. The three before them had run, but only because something unrelated happened to move a number.
The request-lifecycle check decides whether anything is pending without loading a single PHP
file: it builds a slug→timestamp map from filenames, fingerprints it, and takes a one-row
fast path when the ledger already holds that fingerprint. The map came from
MigrationLoader::slugsFromDirectories(), which kept only files matching
YYYY_MM_DD_HHmmss_*.php.
So adding Migration0151.php changed neither the number of files in the map nor the latest
timestamp in it. The key stayed __fw_auto_55_…, every request matched it, and the migration
waited for an unrelated framework migration to change the count — which is what had run the
previous three, by coincidence rather than by design. And a directory holding only
untimestamped migrations produced an empty map, which makes the check return at its first
guard: an application on that convention had no request-lifecycle migrations at all, and
nothing said so.
Fixed¶
slugsFromDirectories() counts every *.php file. A timestamped one contributes its slug
and timestamp as before; anything else contributes the slug its filename implies and an empty
timestamp — which every consumer already treats as "no date, so no cutoff applies", the same
rule filterCutoff() has always applied to a migration object without one.
The slug of an untimestamped migration properly comes from its class short name, and nothing can know that without including the file, which is the one thing this path exists not to do. The filename is that name by convention. Where it is not, the derived slug fails to match the ledger and reads as pending — one request pays for a full load, then the fingerprint records and the fast path resumes. Wrong in the direction of more checking.
The fingerprint also gained a digest¶
Count and latest timestamp cannot see a deploy that removes one migration and adds another, and cannot see anything added behind the latest timestamp. The key now carries a digest of the whole slug→timestamp map, so adding, removing or renaming any file changes it, and nothing has to be guessed about which changes matter. Still no PHP loaded. The first request after upgrading will not match the old key and will do one real check — which is the correct answer to a fingerprint format that has changed.
Both halves are load-bearing, and the tests say which: with the old loader, four cases fail across both engines; with the new loader and no digest, exactly the remove-one-add-one case fails.
One framework migration was named wrong, and this is what found it¶
2026_05_14_000050-AddMissingForeignKeysToExistingTables.php — a hyphen where the convention
has an underscore. It has always run, because the loader reads classes rather than filenames,
but it was invisible to every filename-based count. Renamed to
2026_05_14_000050_add_missing_foreign_keys_to_existing_tables.php, which is the same slug
the class name already derived, so nothing re-runs. The unit test over the real framework
directories now asserts the shape, so the next one cannot be silent.