Skip to content

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
  • BotDetector can 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 in schemaversion and 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 is schema_columns_<database>_<schema>_<table>. The schema is in it because PostgreSQL holds two tables of one name in two schemas, which is what authserver.x relies 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 Database overriding columnCacheCategory() 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-detect in suggest — 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 a require, on the same reasoning as matomo/device-detector beside 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-dev too, so this suite exercises the path an application gets by installing it. That leaves the absent path — the common one — unreachable, so crawlerDetectClass() 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.

$applied = $this->application->databaseVersion();   // '0.150', or null

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.