PostgreSQL native enum types are user-defined types with a fixed set of ordered string values. Unlike every other type in this library, there is no pre-registered constant - each PostgreSQL enum maps to its own subclass of Enum, and each array of one to its own subclass of EnumArray.
Enum is an abstract base class. You create one concrete subclass per PostgreSQL enum type. The subclass declares TYPE_NAME (matching the PostgreSQL type name exactly) and implements getEnumClass() returning the fully-qualified name of a PHP BackedEnum.
Only string-backed PHP enums are supported. PostgreSQL enum values are text labels, so int-backed enums cannot be mapped correctly.
The TYPE_NAME constant must match the PostgreSQL type name exactly - it is used as both the DBAL type name and the SQL declaration.
4. Register the type
useDoctrine\DBAL\Types\TypeasDoctrineType;DoctrineType::addType('sale_status',SaleStatusType::class);// Schema tools (validation, migration diffs) also need PostgreSQL's type name mapped back to it:$platform=$em->getConnection()->getDatabasePlatform();$platform->registerDoctrineTypeMapping('sale_status','sale_status');
Without that mapping, schema introspection fails with Unknown database type "sale_status" requested, Doctrine\DBAL\Platforms\PostgreSQL120Platform may not support it. (the platform class varies with your DBAL and PostgreSQL versions). The framework equivalents:
Symfony: sale_status: sale_status under doctrine.dbal.connections.default.mapping_types in config/packages/doctrine.yaml (setup guide)
Laravel: 'sale_status' => 'sale_status' under the entity manager’s 'mapping_types' in config/doctrine.php (setup guide)
Each PostgreSQL enum requires its own subclass, addType call and platform mapping.
// Two PostgreSQL enums → two subclassesfinalclassSaleStatusTypeextendsEnum{protectedconstTYPE_NAME='sale_status';protectedfunctiongetEnumClass():string{returnSaleStatus::class;}}finalclassPaymentMethodTypeextendsEnum{protectedconstTYPE_NAME='payment_method';protectedfunctiongetEnumClass():string{returnPaymentMethod::class;}}DoctrineType::addType('sale_status',SaleStatusType::class);DoctrineType::addType('payment_method',PaymentMethodType::class);// Schema tools (validation, migration diffs) also need PostgreSQL's type names mapped back to them:$platform=$em->getConnection()->getDatabasePlatform();$platform->registerDoctrineTypeMapping('sale_status','sale_status');$platform->registerDoctrineTypeMapping('payment_method','payment_method');
Arrays of enum values
A column declared as sale_status[] maps to a PHP array of SaleStatus cases through
EnumArray. It is configured exactly like Enum: one
concrete subclass per PostgreSQL enum, declaring TYPE_NAME and implementing getEnumClass(). The only difference is
that TYPE_NAME carries the [] suffix.
useMartinGeorgiev\Doctrine\DBAL\Types\EnumArray;finalclassSaleStatusArrayTypeextendsEnumArray{protectedconstTYPE_NAME='sale_status[]';protectedfunctiongetEnumClass():string{returnSaleStatus::class;}}DoctrineType::addType('sale_status[]',SaleStatusArrayType::class);// Schema tools (validation, migration diffs) also need PostgreSQL's type name mapped back to it:$platform=$em->getConnection()->getDatabasePlatform();$platform->registerDoctrineTypeMapping('_sale_status','sale_status[]');
PostgreSQL reports an array column’s type as the element type prefixed with an underscore (_sale_status), so that is the name to map. The framework equivalents:
Symfony: _sale_status: 'sale_status[]' under doctrine.dbal.connections.default.mapping_types (setup guide)
Laravel: '_sale_status' => 'sale_status[]' under the entity manager’s 'mapping_types' (setup guide)
The scalar and the array type are independent registrations - add whichever ones your schema uses.
PostgreSQL enum labels are arbitrary text, so a label may contain a comma, a space, a double quote, a backslash or be
empty. EnumArray quotes and escapes every element it writes, and reverses that on read, so all of those round-trip
unchanged. Labels that merely look numeric or boolean ('42', 'true') also survive as labels rather than being
coerced to PHP scalars.
NULL elements
A NULL element inside the array maps to a PHP null entry, and null entries are written back as SQL NULL. This is
distinct from a NULL column, which maps to null instead of an array.
Doctrine’s schema tool models tables, not user-defined types, and PostgreSQL constrains what a generated migration could safely do anyway: a label added by ALTER TYPE ... ADD VALUE cannot be used until the transaction that added it commits, and labels can be renamed but not removed. Automatic creation and diffing would therefore have to guess at transactional boundaries and at recreate-and-migrate strategies. Writing the statements yourself keeps that sequencing in your migration tool, where it belongs.
Adding a new case
Since PostgreSQL 12, ALTER TYPE ... ADD VALUE is allowed inside a transaction block, but the new label cannot be used until that transaction commits - PostgreSQL rejects it with unsafe use of new value "returned" of enum type sale_status. A plain addSql() in a Doctrine Migrations up() is therefore enough on its own:
publicfunctionup(Schema$schema):void{$this->addSql("ALTER TYPE sale_status ADD VALUE 'returned'");}
Doctrine Migrations wraps each migration in a single transaction, and preUp() runs inside it too. When the same migration also uses the new label (a backfill UPDATE, a column DEFAULT), move that work into a later migration or make this one non-transactional. On PostgreSQL 11 and earlier, which refuse ADD VALUE on an existing type inside a transaction block, only the non-transactional route works:
Both need the all_or_nothing option off: it runs every migration in one transaction, and refuses a non-transactional one.
Renaming a case
ALTER TYPE ... RENAME VALUE (PostgreSQL 10+) renames a label in place. PostgreSQL stores an enum value by the label’s OID, not its text, so existing rows - array elements included - read back under the new name without being rewritten. The statement is safe inside the migration transaction, and the new label is usable straight away in the same transaction:
publicfunctionup(Schema$schema):void{$this->addSql("ALTER TYPE sale_status RENAME VALUE 'shipped' TO 'dispatched'");}
Change the matching PHP enum case’s backing value in the same deploy. Once the label is renamed, PostgreSQL rejects the old spelling on write, and the old PHP enum has no case for the new one on read (InvalidEnumForPHPException).
Removing a case
PostgreSQL cannot remove an enum label (DROP VALUE is not implemented). Instead, move the data off the label, create a type without it, convert every column that uses the old type, and swap the names. Removing processing from the sales table above:
publicfunctionup(Schema$schema):void{$this->addSql("UPDATE sales SET status = 'pending' WHERE status = 'processing'");$this->addSql("UPDATE sales SET status_trail = array_remove(status_trail, 'processing')");$this->addSql("CREATE TYPE sale_status_new AS ENUM ('pending', 'shipped', 'cancelled', 'returned')");$this->addSql('ALTER TABLE sales ALTER COLUMN status DROP DEFAULT, ALTER COLUMN status_trail DROP DEFAULT');$this->addSql('ALTER TABLE sales
ALTER COLUMN status TYPE sale_status_new USING status::text::sale_status_new,
ALTER COLUMN status_trail TYPE sale_status_new[] USING status_trail::text[]::sale_status_new[]');$this->addSql('DROP TYPE sale_status');$this->addSql('ALTER TYPE sale_status_new RENAME TO sale_status');$this->addSql("ALTER TABLE sales ALTER COLUMN status SET DEFAULT 'pending', ALTER COLUMN status_trail SET DEFAULT '{}'");}
A row still holding the removed label makes the conversion fail, so decide where those rows go first. The defaults are dropped and restored because PostgreSQL cannot cast a default to the new type automatically. The whole sequence runs inside the migration’s transaction. Remove the matching case from the PHP enum in the same deploy.