Skip to content

26 September 2026

4 changes:

  • A migration that queued its statements reported Ran and changed nothing
  • Changing a column was documented on the page nobody writing a migration reads
  • System emails can be rewritten from the mail templates screen, which sent nothing until now
  • …and the framework's own categories are in that screen now, instead of having to be guessed

A migration that queued its statements reported Ran and changed nothing

Found by measuring production after shipping a column widening: two varchar columns were still their old width on development, on the test database and on production, while all three ledgers recorded the migration as having run.

addQuery() queues. executeQueries() is what empties the queue, it is protected, and no framework code called it — so a migration that queues two ALTER TABLEs and returns is a migration that reports success and does nothing.

The care had gone into the wrong half. executeQueries() documents itself carefully: it runs statements tolerantly, because a re-run whose ADD COLUMN is already applied must not abandon the eleven behind it, and it records every rejection so the runner can write "ran, with N rejected". The runner already reads failedStatementSummary() and has RESULT_RAN_WITH_ERRORS for exactly that. All of it was unreachable.

The recovery is the expensive part. A no-op that is recorded cannot be re-run, so correcting the migration file helps nobody who already has the row — the fix has to be a second migration. One mistyped line costs two migrations and a paragraph explaining why there are two.

Fixed

  • Migration::runQueuedQueries(), called by the runner immediately after up() and after down(). Inside the transaction, so a queued statement is committed with the rest of the migration's work.
  • A new public method rather than widening executeQueries() to public: an application's migration may override it as protected, and a parent that became public would make that override illegal — a fatal at class load, for every such installation.
  • A migration that empties its own queue is not run twice. executeQueries() clears the queue, so the runner's call finds nothing left. That has its own test, because the failure it prevents is silent for IF NOT EXISTS and is not for an INSERT.
  • The tests are integration tests against the real database and they assert the schema, not the call. "The runner invoked a method" is not the claim; "the column is wider afterwards" is. Without the fix, two of the three fail — the first with the message this entry is named for.

Documentation

  • Migration Guide — "queued statements run when up() returns", with the warning that anybody holding a migration from before this should check the schema rather than the ledger.
  • Upgrade Guide — a row saying the same, since a recorded no-op is invisible until somebody measures.

Changing a column was documented on the page nobody writing a migration reads

Filed alongside the one above, as "there is no supported way to change a column": a project widening two varchar columns concluded the schema builder could not do it and wrote vendor-specific ALTER TABLE by hand, in three migrations.

There is a supported way, and there has been since v1.2. Blueprint::modifyColumn() compiles to MODIFY COLUMN on MySQL and to the right set of ALTER COLUMN … TYPE / SET NOT NULL / SET DEFAULT sub-statements on PostgreSQL, emitting only the ones whose attributes were actually set. It has integration tests on both dialects and a section in the Schema Builder Guide.

It is mentioned nowhere in the Migration Guide — which is the page somebody writing a migration reads. So the report is right about the experience and wrong about the framework, and the gap it names is real: a feature documented only where the reader is not looking reads as absent. That is the repository's own documentation rule failing in the shape it describes, for the third time this month.

Documentation

  • Migration Guide gains "changing an existing column" — the call, what it compiles to on each dialect, the note that only the attributes you set are touched so retyping does not silently reset nullability or a default, and a link to the full alterTable() vocabulary.

Not done, and why

No change() alias was added. The report suggested $table->string('x', 500)->change() — the idiom a reader coming from elsewhere reaches for — or a documented alternative. The capability exists under a clear name with a guide section; a second spelling for the same thing is more API to keep working, and the thing that actually failed here was a cross-reference. If change() turns out to be what people keep typing, that is a different and better-evidenced argument for adding it.

System emails can be rewritten from the mail templates screen, which sent nothing until now

The mailtemplates table, its model and a full administration screen — list, edit, delete and test send — have shipped for a long time. Nothing ever read a template when sending.

So an operator could write one, save it, send themselves a test of it, and every real message still went out with the text compiled into the notification class. A screen that implies a capability the system does not have is worse than no screen: the text looks changed, and is not, and the test send proves it works.

Added

A notification opts in by declaring its category and the variables it offers:

public function storedMailTemplate(): array
{
    return [
        'category' => 'auth.twofactor_code',
        'vars'     => ['code' => $this->code, 'minutes' => 10, 'sitename' => $this->siteName],
    ];
}

MailChannel is the one place every notification's mail passes through, so the lookup lives there and a notification becomes overridable by declaring a key rather than by repeating a lookup. Read through method_exists() like queueable() and unsubscribeList() beside it, so a notification that declares nothing is never looked up and an installation with no templates pays nothing.

The four the framework ships — auth.twofactor_code, auth.new_device_link, auth.new_signin, auth.security_change — declare theirs.

Empty keeps the default, field by field

A row whose body is blank is an operator who filled in the subject and left the rest alone, not an instruction to send an empty email. A channel that read the row wholesale would send one — silently, to everybody, for as long as nobody looked. So a non-empty template subject replaces the subject, a non-empty body replaces the body, and anything blank keeps what the class wrote. The HTML wrapper is applied too, and wins over one the notification asked for.

An unknown placeholder is left visible rather than blanked: {firstname} in a test send is a mistake somebody can see, and deleting it would make the template look correct in the editor and arrive wrong.

Three things the tests found before an installation did

  • The name mailTemplate() was already taken — by the optional declaration naming the HTML wrapper. Reusing it meant applyOptions() casting an array to a string and setting the wrapper to Array. Hence storedMailTemplate().
  • new MailTemplate() does not work. Model::__construct() requires a Controller, and a notification channel has none — mail is sent from a queue worker, a console command, a second-factor step. The first version of the lookup was an instance method and was therefore broken everywhere it was actually called. It is MailTemplate::lookup(), static, returning an array.
  • A stubbed lookup proves the rules and nothing about the wiring. Both of the above pass every unit test, because those override the lookup to run without a database. There is an end-to-end test against the real table for that reason.

The guard against a failed lookup sits at the call site rather than inside the seam, so an application that overrides templateFor() cannot stop the mail either.

Documentation

  • Notifications Guide — "letting an operator rewrite the text": the declaration, the empty-keeps-the-default table, the language order and why the last step exists, and the categories the framework ships with their variables.

…and the framework's own categories are in that screen now, instead of having to be guessed

The change above gave an operator the ability to rewrite a system email. It did not give them any way to find it: Administration → Mail templates still showed an empty list, and nothing anywhere said that auth.twofactor_code exists, let alone that {code} and {minutes} are the things it may say. The only route in was the guide written the same afternoon.

A feature reachable only by reading the documentation of the thing you did not know existed is a feature nobody uses.

Added

  • SystemMailTemplates — the four categories the framework sends, each with a title, a sentence saying when it goes out, and the placeholders it offers.
  • A migration seeds one row per category, blank. Blank is the whole design: it means "use the built-in text", so a seeded installation behaves exactly as an unseeded one until somebody types something. Seeding the real wording would look friendlier and be a trap — the row would fork from the class the moment the framework improved a sentence or fixed a translation, and the installation would keep sending the old one for ever with nothing to say why. There is an assertion on exactly that, and it is the one that looks pedantic and is not.
  • The editor advertises what the category offers, not only what the text already uses. It read the text and nothing else, which is fine for a written template and useless for the one case that matters — a blank row showed no placeholders at all. Plus the sentence saying when the mail is sent, because auth.new_signin does not tell an operator whether editing it is safe.
  • An operator's existing row is never touched, and running the migration twice seeds once.

The two lists that must agree

What the editor advertises and what a notification supplies are two declarations of one fact, in different files, for different readers. They drift in both directions and each costs differently: a placeholder advertised and not supplied renders as {like_this} in somebody's email — the operator did exactly what the screen told them — and a category a notification answers to but the registry omits is an override nobody can find, which is the state this whole feature was in yesterday.

So SystemMailTemplatesMatchTheNotificationsTest compares them, by reflecting the real notification classes rather than repeating their contents: a copy of the answer would drift with everything else. It walks the directory rather than listing the four, so a fifth notification added without a registry entry fails there instead of being silently unfindable.

Two things the framework's own guards caught

SystemMailTemplates::knows() — written, never called. Coverage is what said so.

And ASweepOverNothingDoesNotPassTest failed three of the new tests: each loops over a call and asserts inside the loop, so a call that returned nothing would have been a silent pass. That is the rule this repository wrote after a sweep examined zero files and reported success, and it applies to a sweep written as a guard exactly as much as to any other.

Documentation

  • Notifications Guide — "they are already in the editor": what the seeding does, why blank, what the editor now shows, and that an application's own categories stay its own business.