heymoon/doctrine-psql-enum
A Doctrine DBAL type for PostgreSQL enums, making it easy to define and use native enum columns in Laravel/PHP applications. Provides seamless mapping and schema integration for cleaner, type-safe database models.
What it gives you: a Doctrine DBAL type that converts a PHP 8.1 backed enum to/from a PostgreSQL CREATE TYPE ... AS ENUM (...) custom type, so $order->getStatus() is an OrderStatus enum instead of a string, and the database column is a real order_status enum rather than varchar + CHECK.
Minimal steps
composer require heymoon/doctrine-psql-enum:^3.4.2 — requires PHP 8.1+, Doctrine DBAL 3/4, PostgreSQL 10+. Pin at least 3.4.2: that patch release forwards registered types to the underlying platform, which changes what the schema tooling emits (see gotchas).enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
}
vendor/heymoon/doctrine-psql-enum/src for the exact base class name and whether it uses getName() or a $name property — this differs between DBAL 3 and 4 releases):final class OrderStatusType extends \Heymoon\DoctrinePsqlEnum\EnumType
{
public const NAME = 'order_status';
protected string $enumClass = OrderStatus::class;
}
Type::addType(OrderStatusType::NAME, OrderStatusType::class);
Symfony (doctrine.dbal.types):
doctrine:
dbal:
types:
order_status: App\Doctrine\Type\OrderStatusType
#[ORM\Column(type: 'order_status')] private OrderStatus $status;$this->addSql("CREATE TYPE order_status AS ENUM ('pending','paid','shipped')");
$this->addSql("CREATE TABLE orders (..., status order_status NOT NULL DEFAULT 'pending')");
Where to look first: the README is thin and the repo is small (25 stars, zero dependents), so treat src/ and especially tests/ as the real documentation — the tests will show the exact registration and schema-integration API for the version you installed. As of 3.4.2 the whole suite is 4 tests, which is a good signal that anything beyond a single round trip is your responsibility to verify. Run one end-to-end round trip (persist → flush → find) against a real PostgreSQL instance before touching anything else.
First use case: an orders.status lifecycle column. It is the highest-value place for a PG enum: the DB enforces the allowed set, ORDER BY status follows your lifecycle order, and the type is inspectable in psql (\dT+ order_status).
One type class per enum. Explicit names (order_status, payment_method) keep the mapping readable and let you use findBy(['status' => OrderStatus::Paid]) — the ORM resolves the column type automatically and DBAL converts on bind. If your installed version supports DBAL 4 service types (constructor-injected enum class), you can register one generic type under several names instead; otherwise subclass.
Write path (ORM):
$order->setStatus(OrderStatus::Paid); // plain enum, no conversion in your code
$em->flush();
Read/query path (DBAL without ORM metadata): pass the type explicitly, otherwise DBAL treats the value as text.
$qb->select('*')->from('orders')
->where('status = :status')
->setParameter('status', OrderStatus::Paid, 'order_status');
DQL works the same way since Doctrine infers the parameter type from the mapped field: WHERE o.status = :status with OrderStatus::Paid bound. Prefer binding enum instances over raw strings (see gotchas).
Schema/migration workflow. Two-phase, always manual on the enum side:
// add a lifecycle step
$this->addSql("ALTER TYPE order_status ADD VALUE 'refunded' AFTER 'shipped'");
Use ADD VALUE ... BEFORE|AFTER so the declaration order (which is the sort order) stays semantic. Keep a single "base schema" migration that owns CREATE TYPE, and never let doctrine:migrations:diff manage it — it will not see enum changes and can generate a DROP TYPE. Since 3.4.2 the platform is handed the registered types, so the schema tooling is at least aware of them on the getCreateTableSQL path; that improves the odds of a correct CREATE TYPE on a fresh build, but it does not make diffing enum changes safe. Keep migrations authoritative.
API boundaries. Let Symfony's Serializer/Validator handle backed enums directly (OrderStatus::from($request->get('status')) in a denormalizer); the DBAL type never has to leak into your HTTP layer. Validate with #[Assert\Choice(callback: [OrderStatus::class, 'cases'])] or a BackedEnumType form field.
Fixtures and seeds. Use enum instances in Alice/Doctrine fixtures — string literals will bind as text and fail against an order_status column in a raw SQL fixture loader.
Where a PG enum is the right tool: small, stable, code-owned vocabularies (statuses, kinds, methods). Where it is wrong: values edited by admins at runtime, or values you expect to rename/remove often — use a lookup table + FK there instead.
operator does not exist: order_status = character varying. The single most common failure. WHERE status = 'paid' works (untyped literal is coerced) but WHERE status = $1 with a text-bound parameter does not. Either bind with the enum type (setParameter(..., 'order_status')) or cast in SQL: status = :status::order_status. The 3.4.2 platform change does not help here — this is a bind-time problem, not a DDL one.migrations:diff on a clean branch and read the output before applying it, and re-run your end-to-end round trip. If you had previously written a custom platform decorator or comparator wrapper to emit CREATE TYPE, check whether it now double-emits the statement and remove it.doctrine:schema:update/migrations:diff don't model enum changes; on some paths they emit DROP TYPE or try to recreate the column as varchar. Exclude those tables from schema_filter, or drive all enum DDL through hand-written migrations.ALTER TYPE ... ADD VALUE and transactions. On PG 12+ the statement is allowed inside a transaction, but the new value cannot be used until commit. Doctrine Migrations wraps migrations in a transaction by default — override isTransactional(): false (or add the value in its own migration) before seeding data with the new case.<type>_new, ALTER TABLE ... ALTER COLUMN status TYPE order_status_new USING status::text::order_status_new, dropping the old type. That rewrites the table and takes an ACCESS EXCLUSIVE lock — plan it, don't ship it casually.ORDER BY status on ('pending','paid','shipped') returns pending → paid → shipped. Changing the order means recreating the type.int-backed enums will produce labels like "0", and pure enums fall back to case names — both usually surprises. Keep labels lowercase, ASCII, no spaces (hyphens and capitals need quoting everywhere, including in DDL).Type::addType() after a connection has resolved types (or after the ORM metadata cache warms) silently leaves you with string. This matters more now that registration also feeds the platform — register too late and the schema tooling won't know the type either. In Symfony do it via doctrine.dbal.types; in plain scripts, first line of bootstrap.search_path. The type lives in a schema; if your table is in app and search_path isn't set, you need app.order_status in DDL and casts.null through untouched; verify once with a ?OrderStatus property rather than assuming.INSERT introduced a label not in your enum, hydration dies with a ValueError. During a migration window, hydrate via a tryFrom()-based fallback in an override of convertToPHPValue().convertToDatabaseValue/convertToPHPValue — with four tests upstream, your own coverage is the only coverage.Doctrine\DBAL\Schema\SchemaManager/comparator wrapper that ignores the type, or a platform decorator that emits CREATE TYPE from enum classes during getCreateTableSQL — but at 25 stars and zero dependents, prefer keeping the DDL in migrations you control rather than patching internals you'll have to re-patch on upgrade. Check first whether 3.4.2's platform wiring already does what you need.How can I help you explore Laravel packages today?