Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Json Odm Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • DBAL types json_document (and jsonb_document on DBAL 4.3+)
  • A serializer service dunglas_doctrine_json_odm.serializer
  • A type mapper service dunglas_doctrine_json_odm.type_mapper

Without 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

  • The Foo/Bar/Baz example in the README is the whole concept in ~20 lines.
  • The #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.

Implementation Patterns

Pattern: documents as value objects, not as pseudo-entities

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.

Pattern: type aliases from day one

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'

Pattern: collections of documents

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

Pattern: querying the document

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');

Pattern: indexing documents

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+.

Pattern: serialization context

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([]);
}

Pattern: extra normalizers

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

Pattern: schema evolution inside the document

Because the column is schema-less, the code has to be tolerant. Two cheap habits:

  1. Give documents default values for every new property (public bool $giftWrap = false;) so old rows hydrate without TypeError.
  2. Version the document explicitly for anything with breaking semantics:
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).

Pattern: API Platform

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.


Gotchas and Tips

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:

  • Renaming a class without type aliases requires a data migration (UPDATE ... SET col = JSON_REPLACE(col, '$."#type"', 'New\\Ns\\Cls')).
  • Rows become stale if you delete a document class — deserialization blows up on hydration of unrelated entity reads.
  • Keep a "legacy" class stub or migrate the data before deleting.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route