Back to Blog
Mautic Doctrine ORM Symfony Debugging Database Migration Australian Business

Mautic `/api/contacts` Returning 500 After Upgrade: The Doctrine Hydration Bug Nobody Warns You About

By Ash Ganda | 10 July 2026 | 6 min read

If you have just upgraded Mautic from the 5.x line to 6.x or 7.x and /api/contacts returns 500 while /api/forms, /api/segments, /api/emails, and /api/campaigns all return 200, you have hit a very specific class of bug that the Mautic forums consistently misdiagnose. Every thread points at GeoIP. In our experience, the vast majority of these 500s have nothing to do with GeoIP — they are a Doctrine ORM hydration failure caused by an unbackfilled NULL in the lead_fields table colliding with Mautic 7’s typed non-nullable properties. This post is the exact debugging path, the one SQL statement that fixes it, and a general pattern for surfacing exceptions that Mautic’s error handler swallows.

The Symptom

Post-upgrade, from a fresh cURL or Postman call authenticated normally:

GET /api/contacts       → 500 Internal Server Error
GET /api/forms          → 200 OK
GET /api/segments       → 200 OK
GET /api/emails         → 200 OK
GET /api/campaigns      → 200 OK
GET /api/companies      → 200 OK
GET /api/contacts/1     → 500 Internal Server Error
GET /api/contacts?limit=1 → 500 Internal Server Error

The 500 response body from Mautic in production mode is generic — a Symfony error page that says An error occurred and gives you nothing useful. The Mautic logs (app/logs/prod.log) show an entry but the stack trace stops at a serializer boundary. No mention of the underlying cause.

Why the Forums Blame GeoIP (And Why That’s Almost Never Right)

Mautic uses a MaxMind GeoLite2 database to enrich contact records with country and city information at read time. A missing GeoLite2 file, or one that is corrupt or malformed, will crash the contact serializer when Mautic tries to attach the geographic metadata. When that happens, /api/contacts returns 500 while other endpoints (which don’t touch GeoIP) return 200. So the shape of the failure — one endpoint down, others fine — matches the GeoIP failure mode exactly.

Except in the class of bug we’re describing here, GeoIP is fine. You can prove it in one command:

docker exec -it mautic_web \
  ls -la app/assets/GeoLite2-City.mmdb

If the file exists, is non-zero bytes, and was modified recently, GeoIP is not your problem. Move on.

The Real Cause: A Migration That Didn’t Backfill

Somewhere in the sequence of Doctrine migrations that ran when you upgraded (whether through doctrine:migrations:migrate or the in-app updater), one migration added a new column to the lead_fields table:

ALTER TABLE lead_fields ADD is_index TINYINT(1) NOT NULL;

Or, more likely, a variation without the NOT NULL constraint at the SQL level but with a default 0 in the entity definition. The migration itself ran cleanly. The column was created. New rows inserted after the migration have is_index = 0 because the entity’s default kicked in. Existing rows from before the migration? They have is_index = NULL.

In Mautic 5.x, the LeadField entity’s isIndex property was untyped PHP (private $isIndex;), so a NULL in the column mapped to a null PHP property without complaint. In Mautic 6.x and 7.x, the entity was updated to use PHP 8 typed properties:

class LeadField
{
    private bool $isIndex = false;
    // ...
}

That single change means the property is a non-nullable bool. Doctrine’s hydrator, which is responsible for reading a row from the database and populating an entity’s properties, cannot assign NULL to a bool typed property. When it tries, PHP throws a TypeError:

TypeError: Cannot assign null to property
Mautic\LeadBundle\Entity\LeadField::$isIndex of type bool

Because the API contact serializer enumerates every custom lead field (via LeadFieldRepository::findAll()) to describe the schema in the response, one NULL row anywhere in lead_fields crashes the entire /api/contacts endpoint. The other endpoints don’t touch LeadField, so they keep working.

How to Surface the Swallowed Exception

Mautic’s HTTP error handler in production mode catches TypeError and other unchecked exceptions and rewrites them as a generic 500. The exception details never reach the logs. To see the actual error, bypass the HTTP stack entirely and invoke the same code path via the PHP CLI, letting the exception bubble to stderr:

docker exec -it mautic_web bash -c '
cd /var/www/html
php -r "
  require \"app/AppKernel.php\";
  \$kernel = new AppKernel(\"prod\", false);
  \$kernel->boot();
  \$repo = \$kernel->getContainer()->get(\"doctrine\")->getRepository(\"MauticLeadBundle:LeadField\");
  var_dump(count(\$repo->findAll()));
"
'

If your LeadField table is clean, this prints an integer (the count of lead fields). If there is a typed-property hydration failure, this prints the exception message and stack trace directly to your terminal, unswallowed. The output will name the entity class, the property, and the expected type — which tells you which column to inspect.

This same pattern works for surfacing any Mautic exception that the HTTP handler eats. Boot the kernel in the CLI, invoke the repository or service that the failing endpoint uses, and read the exception as PHP intended it to be read.

The Fix

Once you know it’s LeadField::$isIndex, the fix is one SQL statement:

UPDATE lead_fields SET is_index = 0 WHERE is_index IS NULL;

Then clear the Symfony cache to be safe:

docker exec -it mautic_web php bin/console cache:clear --env=prod

/api/contacts should return the full contact list on the next request. On our instance, one row was affected and 45,522 contacts were unblocked immediately.

The General Pattern

The core lesson from this bug is broader than Mautic and broader than Doctrine. Any framework migration that adds a new NOT NULL column without a data-cleanup step assumes existing rows are already conformant. That assumption is wrong. Doctrine’s migration generator, Rails’ add_column, Django’s AddField, EF Core’s AddColumn — every ORM’s default behaviour when you add a NOT NULL column to an existing table is to add the column, sometimes with a default that applies to new rows, and to leave existing rows in whatever state the underlying database allows.

Before any framework upgrade that involves migrations, scan the tables the migrations will touch for NULLs in newly-added or newly-constrained columns:

SELECT COUNT(*) AS null_rows,
       'is_index' AS column_name
FROM lead_fields
WHERE is_index IS NULL

UNION ALL

SELECT COUNT(*), 'is_short_visible'
FROM lead_fields
WHERE is_short_visible IS NULL;

If any row returns a non-zero count, backfill before the framework tries to hydrate the entity for the first time. The framework won’t do it for you, and the failure mode will be a swallowed exception that presents as a mysterious 500 on one specific endpoint. That’s the fingerprint. Recognise it early.

For the full story of how we found this on our Mautic 5→7 Docker migration — including the mysqldump command that led us into this situation in the first place — see Mautic 5.2.9 to 7.1.2 on Docker: The Migration Path That Actually Works. For the mysqldump footgun itself, see The mysqldump —databases Footgun.

Ready to upgrade your IT and cloud setup?

Let's talk about cloud, infrastructure, or cybersecurity. We help Sydney SMBs cut hosting costs, harden their stack, and stop firefighting.

Bella Vista, Sydney