
When I moved my own site hoja.in from TYPO3 7.6 to 14.3 this year, I did not type most of the commands myself. Claude Code did, with a project-migration plugin that I use for TYPO3 upgrades. It made the inventory, restructured the repository into a Composer project, wrote the content import script and the new site package, and upgraded the WordPress blog next to it.
That sounds like a marketing sentence, so let me say the important part first: the agent did not decide when something was done. A smoke baseline, a verification gate and my own review decided. This post is about that arrangement, and about the places where the agent was wrong.
Why I did not just say “upgrade my site”
AI output looks plausible. That is the problem. An agent can write a perfect-looking summary (“all pages render correctly”) after checking two URLs, or after checking none. I have seen the same pattern in human pull requests too, to be fair. So before any code changed, I wanted three things that do not depend on anybody’s confidence:
- A baseline: what the old site really delivered, measured.
- A gate: a fixed list of checks that must pass, with real output.
- A ledger: one line per finding, with a status, which the agent must keep up to date and which I can read in two minutes.
Step 1: The baseline
The agent booted the old 7.6 site in a separate DDEV project with PHP 7.0 and the live database dump, then captured seven URLs: status, final URL, bytes, page title and an error marker flag. This is the real file, shortened:
url status bytes title error
https://myport-legacy.ddev.site/ 200 55793 hoja.in | TYPO3 Certified Web Developer no
https://myport-legacy.ddev.site/index.php?id=7 200 60887 About no
https://myport-legacy.ddev.site/index.php?id=13 200 62851 Contact noEven this step had a surprise. The committed 7.6 core had an empty index.php. The agent found it, took the file from the original typo3_src-7.6.10.zip, and wrote it into the ledger instead of quietly “fixing” the repository. That is the behaviour I want.
Step 2: The gate
The plugin has a verify step that runs the project gate. In plain commands, the gate for this project looks like this:
ddev composer show typo3/cms-core | grep versions # really 14.3.x?
ddev composer validate
ddev composer check-platform-reqs
ddev exec vendor/bin/typo3 cache:flush
ddev exec vendor/bin/typo3 upgrade:list # expect: no wizards left
# then: the same URLs as the baseline, plus the new ones, compared
# against the baseline, and new lines in the error logThe rule was simple: the agent may say “done” only with the gate output in front of it. The final run showed the seven baseline URLs with status 200 and no error markers, plus the new speaking URLs (/about, /contact), /blog/, one blog post and sitemap.xml, all 200. On top of that, a real contact form submission had to arrive in Mailpit, with the visitor as reply-to. It did.
Step 3: The ledger
The ledger is a Markdown file with lines like id · area · status · note. Status is one of fixed, decision, deferred or open. A few real lines:
L04 · own-ext · decision · smslogin dropped (user): no plugin on any page of the live DB
L11 · database · fixed · Breaking-17406: pages.url removed, blog link page uses pages.link = /blog/
L20 · blog · deferred · no pre-upgrade visual baseline of the blog; check layout by eye
L23 · deploy · open · production PHP version of the hosting must be >= 8.2Two details made it work. First, the ledger separates my decisions from decisions the agent took on its own, which are marked “open for veto”. Dropping a cookie bar extension without a v14 release was one of those. Second, “deferred” and “open” are allowed. An honest open item is much better than a fake “fixed”.
A guard against the worst mistake
The one thing I could not accept was a lost database. The setup has a hook that blocks database writes when there is no fresh dump. So before the import, before the WordPress database upgrade and before every bigger content change, a dump was written first. The backups folder now has a row of files like pre-v14-import.sql.gz and pre-wp-upgrade-blog.sql.gz. Each one is a point I can go back to, and that lets me sleep better.
Where it went wrong
This is the part most AI success stories skip. The review, done partly by a separate reviewer agent and partly by me, found real problems:
- A wrong claim about a script. The plan first said that a third-party chat script loads on the contact page. A closer look showed it sits only in a content element on a hidden page and was never rendered, before or after. The ledger line says “corrected after review”.
- Wrong page owner. The import first set the CLI user
_cli_as owner of all pages. Fixed to the real admin. - A config file outside git. DDEV was still managing
config/system/additional.php, so the file that switches between DDEV and production was not in the repository. The reviewer marked it as a blocker; now DDEV’s settings management is switched off for it. - A secret coming back. After I saved backend settings, TYPO3 wrote an
encryptionKeyintosettings.phpagain. The ledger has it as open: move it out before committing. - Smoke “regressions” that were not. After the redesign, all seven pages were flagged because the title of the root page changed and the pages got much smaller. Status 200, no errors. That needs my acceptance before the baseline is replaced, not the agent’s.
Also, some content was drafted by the agent from my CV and other indirect evidence. It is marked in the ledger as “owner to review”. An agent can write a nice “What I do” paragraph, but only I know if it is true.
What I learned
- Give the agent checks, not trust. A baseline and a gate turn “looks good” into numbers.
- Make it write things down. The ledger was the cheapest review tool I ever had.
- Keep decisions with a human. The agent can propose dropping an extension; a person says yes.
- Block the dangerous actions technically, not with a polite request in the prompt.
Would I do it again? Yes. The agent saved me a lot of typing and searching, and it was honest whenever the setup forced it to be. The setup is what made the difference, not the model.
The deeper posts on the pieces: a verification gate for AI-written PHP, the hook that blocks database writes without a dump and the smoke test baseline.