PostgreSQL composite (row) types bundle several named fields into a single column type. Like enums, they are user-defined - there is no pre-registered constant, so each PostgreSQL composite type maps to its own subclass of Composite.
Composite is an abstract base class. You create one concrete subclass per PostgreSQL composite type. The subclass declares TYPE_NAME (matching the PostgreSQL type name exactly) and implements getFieldTypes(), which maps each field name to the Doctrine type used to convert it.
A composite column becomes a PHP array keyed by those field names, with each field already converted by its Doctrine type - so an integer field arrives as an int, a date field as a DateTime, a json field as an array.
⚠️ Field order is the contract. PostgreSQL record literals are positional; the field names exist only on the PHP side. If getFieldTypes() lists the fields in a different order from CREATE TYPE, values are silently written into the wrong columns. Keep the two in step.
useDoctrine\DBAL\Types\TypeasDoctrineType;DoctrineType::addType('inventory_item',InventoryItemType::class);// Schema tools (validation, migration diffs) also need PostgreSQL's type name mapped back to it:$platform=$em->getConnection()->getDatabasePlatform();$platform->registerDoctrineTypeMapping('inventory_item','inventory_item');
Without that mapping, schema introspection fails with Unknown database type "inventory_item" requested, Doctrine\DBAL\Platforms\PostgreSQL120Platform may not support it. (the platform class varies with your DBAL and PostgreSQL versions). The framework equivalents:
Symfony: inventory_item: inventory_item under doctrine.dbal.connections.default.mapping_types in config/packages/doctrine.yaml (setup guide)
Laravel: 'inventory_item' => 'inventory_item' under the entity manager’s 'mapping_types' in config/doctrine.php (setup guide)
Note that an unquoted empty field means NULL, while "" means the empty string. The unquoted word NULL is not a null - it is the four-character string "NULL".
Field types
Every field is converted by a registered Doctrine type, so anything Doctrine can convert works - including this library’s own types, provided they are registered.
A field type whose convertToDatabaseValue() returns something other than a string, int, float, bool or null is rejected with InvalidCompositeForDatabaseException - the record literal has no way to carry it.
That includes another composite type: name it in getFieldTypes(), and the nested record is decomposed too. For example, a person whose home field is an address type will arrive as ['name' => 'bob', 'home' => ['street' => '1 Main St', 'city' => 'Sofia']]. Declare the home field as Types::TEXT instead to receive the raw inner literal ("1 Main St",Sofia) and parse it yourself.
Arrays of composites
A composite[] column maps to a PHP array of field-keyed arrays. Extend CompositeArray, declare TYPE_NAME with the [] suffix and point getCompositeClass() at the scalar type’s class:
Register both types, as you would for any other pair of scalar and array types. PostgreSQL reports an array column’s type as the element type prefixed with an underscore, so the array’s schema-tool mapping is _inventory_item:
DoctrineType::addType('inventory_item',InventoryItemType::class);DoctrineType::addType('inventory_item[]',InventoryItemArrayType::class);// Schema tools (validation, migration diffs) also need PostgreSQL's type names mapped back to them:$platform=$em->getConnection()->getDatabasePlatform();$platform->registerDoctrineTypeMapping('inventory_item','inventory_item');$platform->registerDoctrineTypeMapping('_inventory_item','inventory_item[]');
Symfony: _inventory_item: 'inventory_item[]' under doctrine.dbal.connections.default.mapping_types (setup guide)
Laravel: '_inventory_item' => 'inventory_item[]' under the entity manager’s 'mapping_types' (setup guide)
PostgreSQL escapes at both levels - a field holding a comma arrives as {"(\"a,b\",1)"} - which the type handles for you.
Value stability
What this type writes is byte-identical to what PostgreSQL emits for the same value, so a value does not drift across repeated save-load cycles. Three field types are re-rendered by PostgreSQL itself rather than echoed back verbatim; the PHP value still round-trips, but the stored literal differs from the one that was written:
timestamptz is rendered in the session time zone (2024-01-15 10:30:00+00 under UTC, 2024-01-15 12:30:00+02 under Europe/Sofia). The same instant comes back either way.
jsonb normalizes whitespace and key order. Use json if you need to preserve the document verbatim.
numeric keeps the scale you supply (9.90 stays 9.90) but canonicalizes exponent notation (1e10 becomes 10000000000).
Mapping to an object
The base class deliberately maps to an array rather than to a class of your own - there is no single right way to hydrate an arbitrary object.
If you need to narrow that to your own class, hydrate at the entity boundary instead:
No schema introspection. The library never reads pg_type to discover your fields. getFieldTypes() is the single source of truth, which keeps the type usable offline and in unit tests but means you must keep it aligned with your migrations by hand.
No schema generation or diffing. As with enums, CREATE TYPE and ALTER TYPE statements are yours to write.
CASCADE is required when tables already use the type. Update getFieldTypes() after any of them.
Adding or dropping an attribute changes the field count, which is reported as InvalidCompositeForPHPException on the next read rather than silently mis-assigning values. A rename isn’t caught: the count still matches, so reads keep succeeding and simply return the old key names.