adonyarik / pgsql-enums
Native PostgreSQL ENUM types in Laravel migrations (DB and Blueprint macros that create, convert, redefine and drop them safely) and helpers that list the cases of PHP enums.
Requires
- php: ^8.3
- illuminate/database: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- laravel/pint: ^1.32
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.5.22|^13.1.6
Suggests
- ext-pdo_pgsql: The PostgreSQL ENUM macros need a pgsql connection.
Provides
None
Conflicts
None
Replaces
None
README
Native PostgreSQL ENUM types in Laravel migrations, and helpers that list the cases of PHP enums.
- PostgreSQL ENUM macros: DB and Blueprint macros that create, convert, redefine and drop enum types. They check what they can before anything in the database changes, and a change of several steps runs in one transaction.
EnumHelpers: a trait that lists the cases of a PHP enum as names, values, or aname => valuemap.
| Requirement | Version |
|---|---|
| PHP | ^8.3 |
| Laravel | ^13.0 |
| PostgreSQL | 12 or later |
| PHP extension | pdo_pgsql |
- Package:
adonyarik/pgsql-enums - Namespace:
Adonyarik\PgsqlEnums
Table of contents
Installation
composer require adonyarik/pgsql-enums
Laravel package discovery registers PostgresEnumServiceProvider, and nothing needs to be published.
PostgreSQL ENUM
The macros are registered in every process, so migrations work the same from artisan migrate, Artisan::call(), a queued job, a tenancy or module command, or a parallel test worker.
- The DB macros work on the default database connection. The Blueprint macros work on the connection of their
Schema::connection(...)call, so their checks read the database the table statement runs on. Either connection must be PostgreSQL: on another driver the macros throwPostgresEnumExceptionbefore running any query. - A type or table name is either unqualified, and resolves through the connection's
search_pathas it does in plain SQL, orschema.name. A type of the same name in a schema outside the path is ignored, so schema-per-tenant setups can each have their ownpost_status. - The DB macros take table names as they are in the database, with the table prefix if the connection has one. The Blueprint macros add the prefix, as Laravel does.
DB macros
Each line is a separate example:
DB::pgEnumCreate('post_status', ['draft', 'published', 'archived']); DB::pgEnumConvertColumn('posts', 'status', 'post_status'); DB::pgEnumRedefine('post_status', ['draft', 'published', 'archived', 'deleted']); DB::pgEnumRedefineWithDefault('posts', 'status', 'post_status', ['draft', 'published'], 'draft'); DB::pgEnumDrop('post_status'); // once no column uses the type
| Macro | What it does |
|---|---|
pgEnumCreate($type, $values) |
Creates the type in the current schema (the first one on the search_path) or in the schema of schema.name. The values keep their order, which is the order PostgreSQL sorts them in |
pgEnumConvertColumn($table, $column, $type) |
Converts a column, usually a string one, to the type. It checks the existing values first, byte by byte as the cast does, so the conversion either succeeds or does not start |
pgEnumRedefine($type, $values) |
Replaces the values and recasts every column that uses the type, keeping their defaults and the type's owner, comment and privileges, in one transaction. It covers every schema, partitioned tables and child tables, and rewrites each table once, however many of its columns use the type. When the values do not change, it does nothing |
pgEnumRedefineWithDefault($table, $column, $type, $values, $default) |
Same as pgEnumRedefine for a type that only this column uses, then sets the column's default. Partitions and child tables of the table follow it and take the new default too |
pgEnumDrop($type) |
Drops a type that nothing uses |
Notes:
pgEnumConvertColumnfails on a column default that PostgreSQL cannot cast to the enum, such as avarchardefault. Drop the default first and set it again with$table->pgEnumDefault()inSchema::table().- Removing a value is not checked in advance.
pgEnumRedefinerolls back when a row or a default holds a removed value.pgEnumRedefineWithDefaultreplaces the column's default, so only a row can make it roll back. Both then throw Laravel'sIlluminate\Database\QueryException. pgEnumRedefineWithDefaultrefuses a column that does not use the type, and a type that any other column uses, in the same table or another one, in any schema.pgEnumDroprefuses a type that anything uses, and names every column, view, function or other object that does.
Blueprint macros
Schema::create('posts', function (Blueprint $table) { $table->id(); $table->pgEnumColumnNew('status', 'post_status', ['draft', 'published']); // or, if the type already exists: // $table->pgEnumColumn('status', 'post_status'); $table->pgEnumDefault('status', 'post_status', 'draft'); $table->timestamps(); });
pgEnumColumn($column, $type, $options = [])adds a column of an existing enum type.pgEnumColumnNew($column, $type, $values, $options = [])creates the type immediately, before the table statement runs, then adds the column.pgEnumDefault($column, $type, $value)sets the default after checking that the column uses the type, from its definition in the same Blueprint or else from the database, and that the value belongs to the type.
The column macros return the column definition, so modifiers such as ->nullable() chain as usual. $options sets the same attributes, such as ['nullable' => true]; it cannot set type or name.
A migration runs in a transaction on PostgreSQL, so the type pgEnumColumnNew creates rolls back with a failed table. Outside a transaction, as with Schema::create() in application code or a migration with $withinTransaction = false, a failed create table leaves the type behind.
Values and names
- Values are strings. Ints, such as the values of an int-backed PHP enum, are stored as strings:
PostStatus::values()andPriority::values()both work. - A value list must not be empty or repeat a value, and each value must be valid UTF-8 text of at most 63 bytes. A Cyrillic letter takes 2 bytes, so 32 of them are already too long.
- A type or table name must be
nameorschema.name, with each part at most 63 bytes. PostgreSQL would silently cut a longer name. - The macros act only on enum types. A name that resolves to a domain, a composite type, a table's row type or a base type such as
int4throwsnotAnEnum.
Redefining a type: locks and limits
PostgreSQL cannot remove or reorder the values of a type in place, so pgEnumRedefine and pgEnumRedefineWithDefault create a new type and move the columns to it. Changing a column's type rewrites its table and indexes and holds an ACCESS EXCLUSIVE lock on it until the transaction commits. That lock blocks reads as well as writes. To only add a value to a large table, DB::statement("alter type post_status add value 'deleted' after 'archived'") avoids the rewrite, but the new value cannot be used before the migration's transaction commits.
The new type gets the old type's owner, comment and privileges. Giving it the old owner needs a role that may take over objects of that owner, as the role that may rename the type usually can.
Before anything changes, both macros refuse with unsupportedDependents, naming the objects, when the type is used by something PostgreSQL cannot move to a new type:
- array columns of the type, domains over it, composite types, views and materialized views, functions, and foreign tables;
- partial indexes and constraints whose expressions name a value of the type, such as
where status = 'active'orcheck (status <> 'archived'); - views, rules, triggers, policies and publications that use a column of the type, and generated columns computed from one;
- generated columns and partition key columns of the type;
- foreign keys between two tables whose columns use the type.
Drop such objects before the change and create them again after. Plain and unique indexes, and constraints that name no value of the type, are rebuilt as usual.
migrate --pretend
php artisan migrate --pretend prints the SQL of the macros without running it. The macros still read the catalog to build that SQL, and those reads stay out of the printed list. Since the statements before them were only pretended too, the checks that depend on the database state are skipped: a type or table that an earlier step creates is assumed to exist. Input checks, such as names and value lists, still run.
Errors
The checks below throw Adonyarik\PgsqlEnums\Exceptions\PostgresEnumException before anything in the database changes. The message names the type, the table and the values involved, and the exception keeps them in its read-only type, table, column and values properties. Anything PostgreSQL itself refuses throws Illuminate\Database\QueryException, and a change of several steps then rolls back as a whole.
| Factory | When |
|---|---|
typeAlreadyExists |
Creating a type whose name a type, or a table, already takes in the target schema |
typeMissing |
Any operation on a type that does not exist |
notAnEnum |
The name resolves to a type that is not an enum |
schemaMissing |
Creating a type in a schema that does not exist |
tableMissing |
A table that does not exist |
columnMissing |
Converting a column that does not exist |
invalidName |
A name that is empty, has more than one dot or is longer than 63 bytes |
emptyValues |
An empty list of values |
invalidValue |
A value that is not a string or an int, longer than 63 bytes or not valid UTF-8 |
duplicateValues |
A list that repeats a value |
columnHasInvalidValues |
Converting a column that holds values outside the type. Lists 20 of them and counts the rest |
valueNotAllowed |
A default that is not one of the type's values |
defaultNotInValues |
pgEnumRedefineWithDefault with a default that is not one of the new values |
typeStillReferenced |
Dropping a type that something still uses |
columnDoesNotUseType |
Setting a default or redefining with a default for a column that does not use the type |
typeSharedWithOtherColumns |
pgEnumRedefineWithDefault on a type that other columns use |
unsupportedDependents |
Redefining a type that objects PostgreSQL cannot recast depend on |
unsupportedDriver |
A connection that is not PostgreSQL |
reservedColumnOptions |
Column options that set type or name |
Using the manager directly
The macros call Adonyarik\PgsqlEnums\PostgresEnumManager, which is bound as a singleton. They resolve it from the container on every call, so a test double bound with $this->app->instance() replaces it; the Blueprint macros call onConnection() on it first, so a double for them returns itself from onConnection(). Resolve it when you need the checks outside a migration:
use Adonyarik\PgsqlEnums\PostgresEnumManager; $enums = app(PostgresEnumManager::class); // default connection $enums = $enums->onConnection(DB::connection('pgsql')); $enums->enumTypeExists('post_status'); // true $enums->typeExists('post_status'); // true for a type of any kind $enums->allowedValues('post_status'); // ['draft', 'published'] $enums->columnsUsingType('post_status'); // rows with table_schema, table_name, column_name, column_default, inherited, generated $enums->setColumnDefault('posts', 'status', 'post_status', 'draft');
The catalog is always read on the write connection, so a lagging read replica cannot answer the checks.
EnumHelpers
use Adonyarik\PgsqlEnums\Traits\EnumHelpers; enum PostStatus: string { use EnumHelpers; case Draft = 'draft'; case Published = 'published'; } PostStatus::names(); // ['Draft', 'Published'] PostStatus::values(); // ['draft', 'published'] PostStatus::toMap(); // ['Draft' => 'draft', 'Published' => 'published']
The two fit together in a migration and a validation rule:
use Illuminate\Validation\Rule; $table->pgEnumColumnNew('status', 'post_status', PostStatus::values()); 'status' => ['required', Rule::in(PostStatus::values())], 'status' => ['required', Rule::enum(PostStatus::class)], // Laravel's own rule, same result
- The trait requires
cases(), so it works in enums only: a class that uses it fails as soon as it is loaded. - A pure enum (one without values) has
names()only:values()returns[], andtoMap()throws aLogicException.
IDE support
The macros are registered at runtime, so IDEs and static analysers do not see them. resources/ide-helper.php declares them with @method tags; PhpStorm reads it from vendor/ as it is. For PHPStan, add it to scanFiles in phpstan.neon:
parameters: scanFiles: - vendor/adonyarik/pgsql-enums/resources/ide-helper.php
AI agent skills
The package ships two Agent Skills in resources/boost/skills/ and a short guideline in resources/boost/guidelines/core.blade.php. An AI agent working on your application loads a skill only when the task needs it, and the skill gives it the API, recipes and pitfalls, so it does not read the package source every time.
| Skill | Covers |
|---|---|
pgsql-enums-postgres-migrations |
The PostgreSQL ENUM macros. Has up() / down() recipes for creating a type, converting a column, and adding, removing, renaming or reordering values, plus errors, model casts, and tests (--drop-types) |
pgsql-enums-helpers |
The EnumHelpers trait in validation rules, factories and migrations |
With Laravel Boost, they install like the skills of any other package:
composer require laravel/boost --dev # if the application has no Boost yet php artisan boost:install # select the skills feature, then these skills
If the application already has Boost, run php artisan boost:update interactively after composer require adonyarik/pgsql-enums. It finds the new package and offers to publish its skills. Boost copies them into the skills directory of each agent you selected, such as .claude/skills/ for Claude Code, and boost:update refreshes them after the package is updated.
Without Boost, copy the folders into your agent's skills directory:
mkdir -p .claude/skills
cp -r vendor/adonyarik/pgsql-enums/resources/boost/skills/* .claude/skills/
Development
composer install composer test # vendor/bin/phpunit composer lint # vendor/bin/pint --test
The tests run on Orchestra Testbench, in a Unit and a Feature suite. Without a PostgreSQL server, only the tests that need one are skipped. To run them, point them at an empty database you can afford to lose. Every test drops all its schemas and recreates an empty public one:
TEST_PGSQL_HOST=127.0.0.1 TEST_PGSQL_PASSWORD=secret vendor/bin/phpunit
The tests read TEST_PGSQL_{HOST,PORT,DATABASE,USERNAME,PASSWORD}. The defaults are port 5432, database pgsql_enums and user postgres. The PHP pdo_pgsql extension must be installed. A DB_URL in the environment is ignored, and the tests refuse to empty any database but the configured one. One test creates two roles and drops them again, so it needs a superuser and is skipped otherwise. All tests share one database, so they cannot run in parallel (for example with paratest).
A throwaway server that matches the command above:
docker run -d --rm --name pgsql-enums-db -p 127.0.0.1:5432:5432 \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=pgsql_enums postgres:17-alpine
docker stop pgsql-enums-db removes it. When TEST_PGSQL_HOST is set but the server cannot be used, the first test fails with the reason and the rest are skipped. The reason can be a missing PDO extension, a refused connection, or wrong credentials or database.
Every class and method in src/ has a phpDoc block: summary, description, @param / @return / @throws where they apply and @see for the classes and methods it works with. Every test class and method has one too, saying what it checks. pint.json keeps @package and the blank lines between tag groups that this format uses.
The skills in resources/boost/skills/ describe the public API for AI agents. When a macro's signature or behavior changes, update them along with this README and resources/ide-helper.php.
License
MIT © Yaroslav Tyrchenko