
Clicking through the install tool works when you have one installation and one environment. With DDEV locally, a staging server and production, you click the same buttons three times and hope you clicked the same ones. On a multi-site upgrade at work that is simply not an option. Everything that touches the database during an upgrade should be a command you can put in a runbook.
This post covers the core commands in TYPO3 v14 for that job, what each of them really writes, and the part that bit me most: wizards that say “done” although they migrated nothing.
The commands
All of them come with the core when typo3/cms-install is installed (the upgrade wizard interfaces and the #[UpgradeWizard] attribute moved to EXT:core in v14, see Deprecation #106947). In DDEV you run them with ddev exec:
# what is pending, and what was already done
ddev exec vendor/bin/typo3 upgrade:list
ddev exec vendor/bin/typo3 upgrade:list --all
# run all pending wizards, or exactly one
ddev exec vendor/bin/typo3 upgrade:run
ddev exec vendor/bin/typo3 upgrade:run pageDoktypeLinkMigration
# flip a wizard back to "not done"
ddev exec vendor/bin/typo3 upgrade:mark:undone pageDoktypeLinkMigration
# set up extensions: schema, static data, extension configuration
ddev exec vendor/bin/typo3 extension:setup
ddev exec vendor/bin/typo3 extension:setup -e sitepackageOn hoja.in after the move to v14.3, upgrade:list says No wizards available., and upgrade:list --all shows a table with every wizard and its status, for example pageDoktypeLinkMigration as DONE. Keep that in mind, I come back to it.
What extension:setup does to your schema
There is no separate “update schema” command in the core. Schema changes happen in extension:setup. I read PackageSetup::updateDatabaseSchemaForAllPackages() to be sure what it does: it compares all ext_tables.sql definitions with the database and applies only four kinds of changes: add, change, create_table and change_table. It never drops or renames a field or table.
That is a good default for a deploy. Removing old columns is the install tool’s job: Analyze Database Structure in the Maintenance module (in v14 the top-level module formerly called “Admin Tools” is named “System”). The core renames removed fields to zzz_deleted_* first and only drops them in a second step. If you use helhum/typo3-console, it has a database:updateschema command with safe and destructive selectors; check that the release you install supports your core version.
Also good to know: upgrade:run checks the prerequisites of each wizard. Many wizards require an up-to-date database, and that prerequisite adds missing tables and fields before the wizard runs. So the order is not as fragile as it looks. But it still only adds, it does not remove.
Problems I faced: “done” does not mean “migrated”
The status in upgrade:list --all is just a flag in the sys_registry table (namespace installUpdate, one row per wizard class). Nothing checks your data. I found three ways that flag gets set without a migration.
1. A fresh installation marks every wizard as done
When you set up a new TYPO3, SetupDatabaseService::markWizardsDone() sets the flag for all non-repeatable wizards. That makes sense for an empty database. But hoja.in was exactly the other case: a fresh v14.3 installation, then old 7.6 content imported by a script. The wizards were “done” before the old data even arrived.
One of the breaking changes on that way is Breaking #17406: the field pages.url was removed, external link pages use the typolink field pages.link now. The wizard pageDoktypeLinkMigration would move the values, but it was never going to run. So the import script did that mapping itself, and the smoke test confirmed that the “Blog” menu entry still pointed to /blog/. If you import data into a fresh installation, read the wizards of every version you skip and do their work in the import.
2. Running a wizard when the data is not there yet
If a wizard says updateNecessary() is false, upgrade:run marks it done and tells you: “does not need to make changes. Marking wizard as done.” That is fine if the data really does not exist. It is not fine if the data is just not there yet, or not there any more.
The link wizard is a nice example: it only reports work when pages still has the columns url, target and link. Remove url in the install tool before running wizards and the wizard sees nothing to do, gets marked done, and your external links are empty. Order: wizards first, destructive cleanup last.
3. Confirmable wizards with –no-interaction
Some wizards ask a question (they implement ConfirmableInterface). In a deploy script you add -n, so Symfony takes the default answer. If the default is “no” and the confirmation is not required, the command prints “No changes applied, marking wizard as done.” and exits successfully. Green pipeline, nothing migrated.
Solution: check the data, not the flag
Step 1: before any of this, take a database dump. In DDEV that is one command:
ddev snapshot --name before-wizardsOn hoja.in the AI agent that did the legwork ran with a guard hook that refused extension:setup and upgrade:run unless a dump younger than 24 hours existed. That rule is worth keeping even without an agent.
Step 2: list what is flagged as done, straight from the registry:
ddev mysql -e "SELECT entry_key FROM sys_registry WHERE entry_namespace = 'installUpdate'"Step 3: for each wizard that matters for your data, write one query that shows whether the old shape is still present. For the link wizard:
-- external link pages without a link: either migrated wrong, or not at all
SELECT uid, title FROM pages WHERE doktype = 3 AND deleted = 0 AND link = '';Step 4: if a wizard was flagged too early and the source columns still exist, run upgrade:mark:undone <identifier> and then upgrade:run <identifier>. If the columns are already gone, restore the dump. That is why step 1 exists.
A deploy script snippet
On production I do not want to run wizards blindly. They are rehearsed on a copy of the production database, one by one, with the checks above. The deploy only applies the safe schema part and then refuses to continue if anything is still pending:
#!/usr/bin/env bash
set -euo pipefail
composer install --no-dev --optimize-autoloader --no-interaction
vendor/bin/typo3 extension:setup --no-interaction
pending="$(vendor/bin/typo3 upgrade:list --no-ansi)"
if ! grep -q 'No wizards available' <<<"$pending"; then
echo "Pending upgrade wizards, run them on staging first:" >&2
echo "$pending" >&2
exit 1
fi
vendor/bin/typo3 cache:flushThis is close to step 3 of the hoja.in runbook (composer install --no-dev, extension:setup, upgrade:list expecting “No wizards available”, cache:flush), just with the expectation turned into an exit code.
Feedbacks are the champion’s breakfast. If you know another way a wizard gets marked done too early, drop a comment.