
Every TYPO3 developer who has used an AI assistant knows this moment. You ask how to do something in v14, and you get beautiful code with a class name that never existed, or a hook that was removed three majors ago. The answer is fluent, nicely formatted and wrong. The model is not lying. It is averaging over everything it has read, and for TYPO3 that is fifteen years of versions mixed together.
I tried the obvious fix first: longer prompts. “You are a TYPO3 v14 expert. Use only APIs that exist in v14.” It helps a little, like telling a student “please do not make mistakes”. What really helped was changing what the model has in front of it. This post is the pattern I use now when an agent works on an upgrade.
The idea in one sentence
The agent may only claim a change if it can point to the official changelog entry that describes it, and I check that the entry exists.
TYPO3 makes this easy, because the core ships its changelog as files. Every installed core has them under vendor/typo3/cms-core/Documentation/Changelog/, one folder per version, one RST file per change: Breaking-…, Deprecation-…, Feature-…, Important-…. Each has a description, the impact, the affected installations and a migration section. That is exactly the context an agent needs, written by the people who made the change.
Step 1: collect the relevant entries
I do not dump the whole changelog into the context. The v14.0 folder alone is huge. I select by what the code actually uses. A tiny script is enough to start:
#!/usr/bin/env bash
# changelog-context.sh <version-folder> <keyword>...
# Prints the file names of Breaking/Deprecation entries that mention any keyword.
set -euo pipefail
dir="vendor/typo3/cms-core/Documentation/Changelog/$1"
shift
for word in "$@"; do
grep -rlF -- "$word" "$dir" | grep -E '/(Breaking|Deprecation)-' || true
done | sort -u ./changelog-context.sh 14.0 "pages" "'url'" "registerModule" "'web'"The keywords come from the code: table and field names, class names, TypoScript keys. On my hoja.in migration, the old 7.6 database had page records of the type “External URL” with the field pages.url. Searching the 14.0 folder for it finds Breaking-17406-FieldUrlInTablePagesHasBeenRemoved.rst. That entry says the field is gone, that the page type is now called “Link” and uses pages.link, and that an upgrade wizard migrates real external URLs. My import script did that mapping directly, and the comment in the script names the entry. No memory needed.
For larger jobs, my migration plugin has a better version of this script that accepts ids, issue numbers or keywords and falls back to a cached clone of the core repository when there is no Composer vendor copy to read, for example on an old classic-mode site. And if you want filtering by version and type without grep, an MCP server like the TYPO3 Dev Companion does the same lookups as tool calls.
Step 2: feed entries and code together
The prompt then gets three parts: the code to change, the full text of the selected entries, and a strict output format. Something like this:
You migrate the files below from TYPO3 13.4 to 14.3.
Sources you may rely on are ONLY the changelog entries given below.
For every change you make, output one line:
file:line | what changed | changelog file name
If a change is needed but no entry below covers it, do not change the code.
List it under "UNSUPPORTED" with the reason instead.
<entries>
...full text of Breaking-17406-FieldUrlInTablePagesHasBeenRemoved.rst...
...
</entries>
<code>
...
</code>Two things in there do the work. The “only the entries below” rule turns the model from an author into a reader, which it is very good at. And the UNSUPPORTED list gives it an honest way out. Without that exit, a model under pressure to be helpful fills the gap with invention. With it, I get a short list of questions, which is much cheaper than a hidden bug.
Step 3: verify every citation
A citation is only worth something if it is real. Models can invent file names too, with very convincing numbers. So the answer goes through a dumb check before I read it:
changelog=vendor/typo3/cms-core/Documentation/Changelog
grep -oE '(Breaking|Deprecation|Feature|Important)-[0-9]+-[A-Za-z0-9-]+.rst' answer.md | sort -u |
while read -r entry; do
if find "$changelog" -name "$entry" | grep -q .; then
echo "ok $entry"
else
echo "MISSING $entry"
fi
doneAny MISSING line means the answer goes back, no discussion. Any change line without a file name goes back too. This sounds strict, but in practice it costs seconds, and it changes the behaviour of the whole loop: the agent learns inside the session that a source is required, and the answers get shorter and better.
Step 4: the citation is not the review
A real entry can still be applied wrongly. The changelog for module naming in v14 (Feature-107628) says the main module web was renamed to content. An agent reading only that would change every 'parent' => 'web'. But if you read the v14 source, web is registered as an alias and resolved for parent, so an extension that still supports v13 should keep web. The citation tells me where to look. Reading the entry, and sometimes the core code, is still my job. Then tests and a smoke test against a baseline from before the upgrade decide.
Why this works better than a clever prompt
I think of it in terms of what the model has to do. Without sources, it must remember which of many similar APIs belongs to which version. That is hard for people too; I still mix up which version removed what. With sources, it must read and apply a text that is right in front of it. Models are much stronger at the second task. Anthropic’s engineering team writes about this as context engineering: choosing the smallest set of high-signal tokens for the job. The TYPO3 changelog is about the highest-signal text you can find for an upgrade, and it is free.
The side effect I like most: every change in the merge request now carries its reason. A reviewer, or me in six months, can open the RST file and see why a line changed. That alone was worth the effort.
If this pattern helps you in your next upgrade, I will be really happy. Cheers!