Felix Dziekan

Gepostet von: Felix Dziekan in: Blog am Apr 25, 2026

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:

{
    "name": "vendor/my-extension",
    "type": "typo3-cms-extension",
    "require": {
        "typo3/cms-core": "^10.4 || ^11.5 || ^12.4 "
    },
    "extra": {
        "typo3/cms": {
            "extension-key": "my_extension"
        }
    }
}

JSON

Then register the extension as a local path repository in the root composer.json:

"repositories": [
    {
        "type": "path",
        "url": "packages/my_extension"
    }
]

JSON

Move the extension from typo3conf/ext/my_extension to packages/my_extension, then require it:

composer require vendor/my-extension:@dev

Bash

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:

# Update TYPO3 core and compatible extensions
composer require typo3/cms-core:"^11.5" --update-with-all-dependencies

# Run database schema updates
vendor/bin/typo3 database:updateschema

# Run all pending upgrade wizards
vendor/bin/typo3 upgrade:run

Bash

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.

# Before: old TYPO3 10.4 server
backend typo3_blogs
  server srv typo3_old:80

# After: new TYPO3 12 server — change and reload haproxy
backend typo3_blogs
  server srv typo3_new:80

HAProxy

systemctl reload haproxy

Bash

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:

# Warm Varnish cache from sitemap
curl -s example.com \
  | grep -oP '(?<=)[^<]+' \
  | xargs -P 4 -I {} curl -s -o /dev/null {}

Bash

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.

Bye Bye

Well that's it from my side for today.
Have a good one!

Hire Me: From small to big business

Whether you're a nimble startup needing a consultant who can guide you through the complex IT-Jungle or a large-scale international company seeking a skilled team player, I'm here to help.