Migrating a TYPO3 installation from version 10.4 ELTS to v12 LTS is not something you do in an afternoon. It spans four major versions, requires converting a classic installation to Composer, and involves custom extensions that were never designed to survive an upgrade. This post documents exactly how I approached it — step by step, without downtime, and with automated tests to prove nothing broke.
The Starting Point
The project: a multisite TYPO3 10.4 ELTS setup, classic installation (no Composer), several custom extensions installed via Extension Manager, and a hard requirement that all websites must look and behave identically after the migration. No visual regressions, no broken routes, no missing content.
The target: TYPO3 v12.4 LTS with a full Composer setup, running on PHP 8.2.
The upgrade path is not a single jump. You have to go through every major version:
10.4 → 11.5 → 12.4
Skipping versions isn't supported. Each step has its own upgrade wizards, deprecations, and database schema changes. Respect the path.
Before you touch a single file, make a full backup. Database and filesystem. Then make another one. Migrations go wrong, and "I thought I had a backup" is not a recovery strategy.
Step 1: Audit Everything First
Before writing a single line of code, I audited the full installation:
- Which third-party extensions are installed, and do they have versions compatible with v12?
- Which custom extensions exist, what do they do, and what TYPO3 APIs do they use?
- What PHP version is the server running? (v12 requires PHP 8.2+)
- Is there any TypoScript that uses deprecated or removed features?
For third-party extensions, check the TYPO3 Extension Repository (TER) and Packagist. If an extension hasn't been updated for v12, you either need to find an alternative, maintain a fork, or rewrite it. Find this out before you start — not halfway through the migration.
For custom extensions, run the TYPO3 Extension Scanner in the install tool. It flags deprecated API usage and tells you exactly what needs to be updated for each version. Run it before the migration and bookmark the output.
Step 2: Set Up Functional Tests Before Touching Anything
The most important thing you can do before a migration is capture a baseline of the current system. This gives you something to compare against after each upgrade step. If the test passes, you know the site still works as expected. If it fails, you know exactly what broke and when.
My approach: crawl all URLs from every site's sitemap, extract the main content area, strip dynamic parts, and store the MD5 hash of what remains. After each migration step, run the same crawl and compare the two snapshots.
A full-page MD5 hash is too fragile - timestamps, session tokens, cache busters and nonces in the rendered HTML will produce a different hash even if nothing actually changed. Instead, I extract only the main content area of each page (the tag or the primary content div), strip out all tags and dynamic attributes like data-* and nonce, and hash only what's left. That's the content your users actually see - and that's what the migration must not break.
For each URL the script records three things: the HTTP status code, the page title, and the content hash. Everything gets saved to a JSON file. After each upgrade step, run the script again against the new environment and diff the two files. Any URL with a changed hash, a different status code, or a changed title is a potential regression that needs investigation before going further.
Don't hash the full HTML — timestamps, cache busters and nonces will make every hash different even when nothing changed. Hash only the main content area after stripping dynamic attributes. That's what your users actually see.
Step 3: Convert Custom Extensions to Composer Packages
If extensions were installed via the TYPO3 Extension Manager, they live in typo3conf/ext/ and have no composer.json. Composer doesn't know they exist. You need to fix this before anything else.
For each custom extension, add a composer.json to its root:
Then register the extension as a local path repository in the root composer.json:
Move the extension from typo3conf/ext/my_extension to packages/my_extension, then require it:
Extensions installed via Extension Manager are invisible to Composer. Without a composer.json and a path repository entry, Composer will simply not include them when it builds the vendor directory — and your extension silently disappears.
Step 4: Set Up TYPO3 Locally in the Old Version
With all extensions packaged up, I set up a fresh local TYPO3 10.4 installation using Composer and imported the production database. This becomes the base for the upgrade process. The goal at this point is a running 10.4 installation under Composer that is functionally identical to the live system.
Run the snapshot script against this local installation to confirm the hashes match production before moving forward.
Step 5: Step-by-Step Version Upgrades
For each major version, the process is the same. Here's the upgrade from 10.4 to 11.5 as an example:
After each version, run the Extension Scanner in the install tool against your custom extensions and fix every flagged deprecation before moving to the next version. Do not skip this step. Deprecations in v11 become fatal errors in v12.
Repeat for 11.5 → 12.4 At the 12.4 step, make sure your server (or Docker container) is on PHP 8.1 minimum.
Also at v12: TypoScript syntax changed significantly. The old-style page.10 setup still works, but the new site configuration takes over a lot of what previously lived in TypoScript. Review your setup carefully - especially if you're running a multisite installation.
Step 6: Stage Deployment and Testing
Once the local upgrade is clean and the snapshot tests pass, the migrated codebase goes to stage. The stage environment runs the same database as prod (anonymized if necessary) and is the first real test of the full server stack.
Run the snapshot script against stage, compare to the baseline. Walk through every site manually for anything the automated tests might miss — forms, frontend login, search, language switcher.
Step 7: Production on a Non-Public Domain
Before switching live traffic, I deploy to production infrastructure but behind a non-public hostname. This tests the real server environment — the actual database, the actual file system, the actual PHP version — without exposing anything to users.
Run the snapshot script one final time against this environment. If the hashes match, we're ready to go live.
Step 8: Zero-Downtime Cutover via HAProxy
This is where having a proper proxy setup pays off. Instead of a DNS switch (which has TTL propagation delays and a window where some users hit the old server), I switch the backend in HAProxy. One config change, one reload — instant cutover, zero downtime.
Traffic switches instantly. The old server stays running for 30 minutes as a rollback option. If anything looks wrong, one more reload points traffic back. After 30 minutes of clean monitoring, the old server gets shut down.
DNS-based cutovers give you a propagation window where some users hit the old site and some hit the new one — for potentially hours. A proxy-level cutover is instant and reversible in seconds. If you have a load balancer in front, use it.
Step 9: Warm Up the Cache
After the cutover, Varnish's cache is cold every request goes through to TYPO3 until pages get cached again. For a high-traffic site, this can cause a load spike right after go-live. Warm the cache by crawling your sitemap URLs immediately after the switch:
The -P 4 flag runs 4 parallel requests. Adjust based on how many pages you have and how much load your server can handle during warmup.