dunglas/doctrine-json-odm
Store PHP objects as JSON documents in PostgreSQL JSONB or MySQL JSON columns alongside traditional Doctrine ORM mappings, enabling schema-less, NoSQL-like hybrid data models with fast querying and indexing.
1. Install and (usually) do nothing else
composer require dunglas/doctrine-json-odm
If you're on Symfony with Flex, the bundle is registered automatically and you get:
json_document (and jsonb_document on DBAL 4.3+)dunglas_doctrine_json_odm.serializerdunglas_doctrine_json_odm.type_mapperWithout Symfony you must register the types manually before creating the EntityManager — this is the part people get wrong, because Type::getType('json_document') throws if registration happens after the EM boots:
$serializer = new Serializer(
[new BackedEnumNormalizer(), new UidNormalizer(), new DateTimeNormalizer(), new ArrayDenormalizer(), new ObjectNormalizer()],
[new JsonEncoder()]
);
Type::addType('json_document', JsonDocumentType::class);
Type::getType('json_document')->setSerializer($serializer);
if (class_exists(JsonbType::class)) { // DBAL 4.3+
Type::addType('jsonb_document', JsonbDocumentType::class);
Type::getType('jsonb_document')->setSerializer($serializer);
}
2. First use case: the "hybrid entity"
The mental model to adopt on day one: the relational columns are the stable, queried, indexed part of your model; the JSON column is the flexible, schema-less tail. Start with one field on an existing entity rather than a new subsystem.
#[Entity]
class Product
{
#[Column, Id, GeneratedValue]
public int $id;
#[Column]
public string $sku; // relational: indexed, unique, joins
#[Column(type: 'json_document', options: ['jsonb' => true])]
public ProductAttributes $attributes; // document: varies per category
}
The value can be a POPO, an array of POPOs, scalars, nested graphs — anything the Symfony Serializer round-trips. It is not an entity: no ID, no identity map, no lazy loading, no relations.
3. Where to look first
Foo/Bar/Baz example in the README is the whole concept in ~20 lines.#type key hidden in every serialized object is the thing that will bite you later (see Gotchas) — read the "type aliases" and "namespace changes" sections before shipping to production, not after.ScientaNL/DoctrineJsonFunctions is the natural companion if you intend to filter on documents in DQL.Keep documents free of Doctrine annotations and free of behaviour that needs the database. Model them as immutable-ish value objects with public typed properties (or getters/setters), and let the entity own the lifecycle:
final class ShippingSpec
{
public string $carrier;
public float $maxWeightKg;
public array $allowedCountries = []; // scalars serialize fine
}
This keeps the JSON column trivially testable in isolation and prevents accidental "relations" that the ODM cannot support.
Without aliases the FQCN is written into every row as #type. Renaming or moving a class then becomes a data migration.
# config/packages/doctrine_json_odm.yaml
dunglas_doctrine_json_odm:
type_map:
shipping_spec: App\Document\ShippingSpec
warranty: App\Document\Warranty
Stored as {"#type": "shipping_spec", ...} — shorter rows, refactor-safe, and old FQCN-based rows still deserialize. Aliases can be introduced retroactively.
For non-Symfony setups or dynamic maps (e.g. tenant-specific document sets), implement TypeMapperInterface and swap the service:
services:
dunglas_doctrine_json_odm.type_mapper: '@App\Document\MyFancyTypeMapper'
Typed collections work, including generics-style nesting, but the ArrayDenormalizer must be present (it is by default in the bundle) and each element carries its own #type:
#[Column(type: 'json_document')]
public array $lineItems = []; // LineItem[]
If you need heterogeneous content, declare array (or mixed) — the serializer dispatches on #type per element, so a single column can hold a mix of document classes. Handy for event/audit payloads:
#[Column(type: 'json_document', options: ['jsonb' => true])]
public array $payload = []; // any graph of known documents
You cannot add JSON paths to DQL criteria, but you have two practical routes.
Native SQL for reports and read models:
$sql = <<<SQL
SELECT id, sku, attributes->>'carrier' AS carrier
FROM product
WHERE attributes @> :filter
SQL;
$rows = $em->getConnection()->fetchAllAssociative($sql, [
'filter' => json_encode(['carrier' => 'dhl']),
]);
DQL functions via scienta/doctrine-json-functions when you want to stay inside the ORM:
$qb->andWhere('JSON_GET_TEXT(p.attributes, :path) = :carrier')
->setParameter('path', 'carrier')
->setParameter('carrier', 'dhl');
The ORM will create the column but not the document indexes. Add them in a raw-SQL migration — for PostgreSQL, a GIN index is the workhorse:
$this->addSql('CREATE INDEX product_attributes_gin ON product USING GIN (attributes)');
$this->addSql("CREATE INDEX product_carrier ON product ((attributes->>'carrier'))");
For MySQL 5.7+/8, use generated columns + a regular index, or multi-valued indexes on 8.0.13+.
Escaped slashes and pretty-printing are the usual reasons to touch the context. In Symfony, override it at boot — after parent::boot():
public function boot(): void
{
parent::boot();
$type = Type::getType('json_document');
$type->setSerializationContext([JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE]);
$type->setDeserializationContext([]);
}
Anything non-trivial (money objects, enums beyond BackedEnumNormalizer, custom identifier objects) needs a normalizer registered in the ODM serializer — the framework serializer is not reused. Override the service and be explicit about order:
dunglas_doctrine_json_odm.serializer:
class: Dunglas\DoctrineJsonOdm\Serializer
arguments:
- ['@App\Serializer\MoneyNormalizer', '@?dunglas_doctrine_json_odm.normalizer.backed_enum', '@?dunglas_doctrine_json_odm.normalizer.uid', '@dunglas_doctrine_json_odm.normalizer.datetime', '@dunglas_doctrine_json_odm.normalizer.array', '@dunglas_doctrine_json_odm.normalizer.object']
- ['@serializer.encoder.json']
- '@?dunglas_doctrine_json_odm.type_mapper'
public: true
Because the column is schema-less, the code has to be tolerant. Two cheap habits:
public bool $giftWrap = false;) so old rows hydrate without TypeError.final class LoyaltyProfile
{
public int $v = 2;
public int $points = 0;
public ?string $tier = null;
}
…and normalize lazily on read, or run a one-off migration with JSON_SET/JSON_REPLACE (the README shows the MySQL idiom for rewriting #type).
On API Platform, documents are exposed as nested JSON in your resource output for free — no subresource needed. This is the sweet spot for "arbitrary metadata the client defines" payloads. Guard the input side: documents bypass the normal request validation of entity properties, so validate with a custom constraint that runs the Serializer + Validator over the denormalized graph.
Nested mutation is not detected. Doctrine compares entity state by reference. $foo->misc[0]->title = 'x' mutates the object that is also stored as the original snapshot, so both sides look identical and no UPDATE runs. Fixes, in order of preference:
// 1. Reassign a modified copy
$items = $foo->misc;
$items[0] = clone $items[0];
$items[0]->title = 'x';
$foo->misc = $items;
// 2. Or clone the whole graph before mutating
This is the single most common bug report against this package. Treat documents as immutable value objects and you sidestep it entirely.
Only public properties (or getter/setter pairs) are serialized. With the default ObjectNormalizer, a private property without an accessor is silently dropped on write and left uninitialized on read — no exception, just missing data. Watch for this when converting an existing entity with private state.
#type is data, not metadata. Every object you persist embeds its class (or alias). Consequences:
UPDATE ... SET col = JSON_REPLACE(col, '$."#type"', 'New\\Ns\\Cls')).jsonb option vs jsonb_document type. options: ['jsonb' => true] is the legacy way (DBAL < 4.3) and only makes sense on PostgreSQL. On DBAL 4.3+ prefer type: 'jsonb_document'. Setting jsonb on MySQL will produce broken DDL.
No partial updates. The whole column is rewritten on every flush. Large documents in hot rows mean write amplification, WAL churn, and replication cost. Keep documents under a few KB; if a document is the primary reason for a write, consider whether it belongs in a table.
Hydration has no identity map. The same document class inside two entities produces two distinct PHP objects. Don't rely on === across documents; implement equals() where it matters.
Type registration is process-global. In long-running workers (Messenger, FrankenPHP, RoadRunner) the serializer and its context are set once at boot. Changing type_map or the serializer service at runtime requires a worker restart — a classic "works in the test, stale in the worker" gotcha.
Test setup divergence. SQLite stores the JSON as TEXT; everything round-trips, but ->>, @>, and GIN indexes don't exist. Unit-test serialization in isolation and keep JSON-path queries covered by at least one PostgreSQL/MySQL integration test.
Serializer failures are hard to trace. A denormalization error surfaces as a DBAL conversion exception during find(), often on an unrelated column. When debugging, dump the raw column value and try $serializer->deserialize($json, ...) by hand — the nested exception is rarely the top-level message.
Extension points worth knowing: TypeMapperInterface (class name ↔ alias), a custom Serializer service, setSerializationContext()/setDeserializationContext() on the type, and normalizer ordering in the service definition. Almost everything else in this package is intentionally left to Doctrine.
How can I help you explore Laravel packages today?