Mautic 5.2.9 to 7.1.2 on Docker: The Migration Path That Actually Works in 2026
This is a first-hand write-up of upgrading our self-hosted Mautic from 5.2.9 to 7.1.2 on Docker. The community’s collective advice is unambiguous: skip in-place upgrades between major versions, do a fresh install on new volumes, re-import your contacts. We started down that path. A mysqldump command with an easy-to-miss flag accidentally overwrote the fresh 7.1.2 database with the 5.2.9 backup — which forced us to run the migration path everyone says not to try. It worked. Ninety Doctrine migrations executed cleanly across two major versions with no data corruption. But it left one silent landmine that broke /api/contacts and returned 500 errors on every contact list call. This post walks through what happened, why the direct Doctrine path succeeded where the in-app updater fails, and the one-line SQL fix for the landmine so you can avoid the same 30 minutes of debugging.
What We Were Trying to Do
We run Mautic self-hosted on Docker to handle marketing automation for our own consultancy and a handful of client campaigns. The instance had been sitting on Mautic 5.2.9 for the better part of a year — reliable, but two majors behind current. Mautic 7 shipped a real REST API v2, a leaner UI, PHP 8.2+ support, and Symfony 6 under the hood. The upgrade was overdue.
Our starting point:
- Mautic 5.2.9 running in a Docker container on a Linode 4GB instance
- 45,522 contacts, 51 forms, 24 campaigns, 91 email templates
- MariaDB 10.6 in a sidecar container
- Full nightly backups via
mysqldumppiped throughgzip
The target: Mautic 7.1.2, same host, minimal downtime, no data loss.
The Fresh-Install Path Everyone Recommends
Search “mautic 5 to 7 docker” and every second thread says the same thing: don’t do in-place upgrades. The Mautic community has a well-documented set of horror stories about the in-app updater and CLI updater corrupting Docker installs when jumping major versions. The advice is uniform:
- Spin up a fresh Mautic 7 container on new volumes.
- Export your contacts, forms, campaigns, and templates from the old instance.
- Re-import into the fresh 7 instance.
- Reconnect DNS.
- Archive the old instance.
We followed it. Day 1: fresh 7.1.2 stack came up clean on new volumes. Empty schema, no data, no yellow warnings. Day 2: bulk-imported 14,447 contacts from the CSV export we’d generated from the 5.2.9 instance’s admin UI. Everything looked right. We celebrated slightly early.
Day 3, the client asked a question that changed the plan: “have you restored all the campaigns and forms that were there previously?” We hadn’t. The CSV export doesn’t include campaigns, forms, or email templates — those live entirely in the database. Rebuilding 51 forms and 24 campaigns by hand wasn’t going to happen in an afternoon. We needed to inspect the old dump to enumerate what was missing, decide which pieces were still relevant, and manually recreate the important ones.
That’s when it went sideways.
The mysqldump Command That Rewrote the Live Database
To inspect the old dump without disturbing the live 7.1.2 database, we loaded it into a side database called mautic_oldinspect:
zcat mautic_backup_2026_06_15.sql.gz | mariadb -uroot mautic_oldinspect
The intent was straightforward — restore the old dump into a scratch database, query it for the missing forms and campaigns, then copy what we needed into the live 7 instance one by one.
The dump had been generated with:
mysqldump --databases mautic > mautic_backup.sql
That --databases flag looks harmless. It isn’t. When you use it, mysqldump silently embeds three statements at the top of the dump file:
DROP DATABASE mautic;
CREATE DATABASE mautic;
USE mautic;
The USE mautic; line redirects every subsequent INSERT in the dump away from whatever target database you named on the mariadb command line. It doesn’t matter that we told the client tool to load into mautic_oldinspect — the moment MariaDB parsed USE mautic;, every insert after that landed in the live mautic database. Which had just been dropped and recreated by the two statements immediately above.
By the time we noticed, the fresh 7.1.2 schema was gone. In its place: the entire 5.2.9 schema, plus all 45,522 original contacts, 51 forms, 24 campaigns, 91 email templates, and every scrap of history we thought we had walked away from.
If you take one thing from this post, take this: mysqldump --databases is a footgun. Any dump made with that flag will silently overwrite the live database of the same name if you pipe it into the wrong target. Use --no-create-db when producing dumps you might inspect later, or restore into a genuinely isolated MariaDB server, not just a differently-named database on the same instance.
Running doctrine:migrations:migrate Across Two Majors
At this point we had Mautic 7.1.2 PHP code running against a Mautic 5.2.9 database schema, with all our original data. The obvious move was to restore from a backup of the empty 7.1.2 schema — but that would put us back where we started, staring at an empty instance and 51 forms we still hadn’t restored.
The other move was the one the community says don’t try:
docker exec -it mautic_web \
php bin/console doctrine:migrations:migrate --no-interaction
We took it. The command enumerated every Doctrine migration between the 5.x baseline and 7.1.2 — ninety migrations across two major versions. They executed sequentially. No corruption warnings. No schema conflicts. No orphaned foreign keys. The web UI came up clean at the end. Contacts loaded, segments loaded, forms loaded, campaigns loaded.
This surprised us. The community lore is grounded in real pain — but reading the forum posts closely, most of the horror stories are about upgrades run through Mautic’s in-app updater or the CLI updater command (php bin/console mautic:update:apply), which packages both file-system updates and DB migrations behind an orchestrator that doesn’t handle Docker’s filesystem constraints well. Direct doctrine:migrations:migrate against a fresh container that already has the correct code doesn’t hit any of those orchestrator failure modes. It’s just Doctrine running SQL, and Doctrine’s migration engine is boring in the good way.
If you’re on Docker and considering an in-place upgrade, the sequence that worked for us was:
- Backup the database. Twice.
mysqldump --single-transactionthis time, not--databases. - Update the Docker image tag on the
mautic_webservice to the target major (mautic/mautic:v7.1.2-apachein our case). - Recreate the container so the new code is running against the old schema.
- Run
doctrine:migrations:migratefrom inside the new container. - Clear the Symfony cache:
php bin/console cache:clearandcache:warmup.
The One Silent Landmine: /api/contacts Returning 500
We had contacts back, forms back, campaigns back, and a working UI. We started re-integrating our other services with Mautic’s REST API v2. And that’s where we hit a wall.
GET /api/contacts returned 500 Internal Server Error. Consistently, immediately, on every call. But every other endpoint returned 200:
GET /api/forms— 200 ✅GET /api/segments— 200 ✅GET /api/emails— 200 ✅GET /api/campaigns— 200 ✅GET /api/contacts— 500 ❌
Every Mautic forum post about a 500 on /api/contacts after a migration pointed at GeoIP. Mautic uses a MaxMind GeoLite2 database to enrich contact records with country information, and a missing or corrupted GeoIP file will crash the contact serializer at load time. We checked. GeoIP was fine. That wasn’t it.
Mautic’s error handler is generous with a Symfony HttpException in production mode and swallows the underlying stack trace. To see what was actually breaking, we bypassed the HTTP stack entirely and invoked the same code path via the PHP CLI, letting the exception bubble to stderr:
docker exec -it mautic_web php -r "
require 'app/AppKernel.php';
\$kernel = new AppKernel('prod', false);
\$kernel->boot();
\$container = \$kernel->getContainer();
\$repo = \$container->get('doctrine')->getRepository('MauticLeadBundle:LeadField');
\$fields = \$repo->findAll();
echo count(\$fields) . \"\n\";
"
The exception came out immediately:
TypeError: Cannot assign null to property
Mautic\LeadBundle\Entity\LeadField::$isIndex of type bool
There it was. One of the ninety migrations added an is_index column to lead_fields. The migration ran cleanly — no errors, no warnings. But it didn’t backfill existing rows. Every existing row got is_index = NULL. When the new Mautic 7 code loaded, its LeadField entity declared isIndex as a typed non-nullable bool using PHP 8’s typed properties. Doctrine’s hydrator tried to assign NULL to a bool and PHP threw a TypeError before the object was even returned to the caller.
Because every contact serializer touches LeadField (to enumerate custom fields on the contact), one NULL row in lead_fields cascaded up to a 500 on every endpoint that returned contact data. The other endpoints didn’t touch LeadField and stayed green — which is why forms and segments and emails all worked and only /api/contacts broke.
The Fix and How to Prevent It on Your Migration
The fix was one line:
UPDATE lead_fields SET is_index = 0 WHERE is_index IS NULL;
One affected row. /api/contacts returned all 45,523 results on the next request.
The general lesson is: Doctrine’s migration generator does not add data-cleanup steps for existing rows when you add a NOT NULL column. It assumes your rows already conform to the new schema — which is a fine assumption for a schema you designed alongside the migration, and a lousy assumption for a two-major-version jump. Mautic’s own migrations follow the Doctrine defaults, so they carry this same assumption forward across every major.
Before you run doctrine:migrations:migrate on any Mautic upgrade, run this pattern against every table the migrations will touch:
SELECT
COUNT(*) AS null_rows,
'<column>' AS col
FROM <table>
WHERE <column> IS NULL;
For Mautic 5→7 specifically, the columns worth checking before you migrate are:
lead_fields.is_index(this one)lead_fields.is_short_visible(introduced 6.x)emails.utm_tags(introduced 6.x, JSON default{})campaigns.canvas_settings(introduced 6.x, JSON default{})
If any of them return a non-zero count, backfill with the appropriate default (0 for bools, '{}' for JSON) before the migration runs. The Doctrine generator won’t do it for you and Mautic’s error handler will swallow the exception when a hydration failure hits the API.
Should You Actually Try This Path?
Two answers, depending on where you’re standing.
If you already have a running Mautic 5 instance you can’t afford to lose, the direct Doctrine path is now battle-tested (at least on our stack: MariaDB 10.6, PHP 8.2, Docker Compose, Apache). It preserved every record, every relationship, and every association. If you’re willing to run the pre-migration null-check we outlined above, the migration itself is boring.
If you’re standing up a brand new Mautic install, we still recommend Mautic 7.1.2+ over 5.x — the API is materially better and the Symfony 6 base gives you a longer support runway. But if you’re new to Mautic and reading this, look hard at whether self-hosting is the right call at all. Between the Docker upgrade dance, the CVE patching (see our post on CVE-2018-8092), and the operational overhead of running your own SMTP for deliverability, hosted alternatives like EngageBay or SendFox often work out cheaper by the time you count your own hours. We have a full side-by-side in EngageBay vs Salesforce vs Mautic vs SendFox vs Salesflare vs Twenty CRM for Australian small business.
For anyone determined to self-host: back up before you touch anything, use --single-transaction not --databases, run the null check before you migrate, and know that /api/contacts will lie to you when a typed-property hydration fails — the 500 is honest, the error message isn’t.
Related Reading
- Security Alert: Mautic CSV Injection (CVE-2018-8092) — the other Mautic gotcha every self-hoster should patch.
- EngageBay vs Salesforce vs Mautic vs SendFox vs Salesflare vs Twenty CRM — when hosted beats self-hosted for Australian SMBs.
- Hacked WordPress Site Cleanup with Sucuri, Wordfence, and MalCare — different tech stack, same operational discipline.