Search by

adonyarik / pgsql-enums

ADONYARIK

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.

Package info

github.com/ADONYARIIK/pgsql-enums

pkg:composer/adonyarik/pgsql-enums

Statistics

Installs: 1

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v1.0.0 2026-10-22 14:27 UTC

This package is auto-updated.

Last update: 2026-10-22 19:09:55 UTC


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 a name => value map.
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 throw PostgresEnumException before running any query.
  • A type or table name is either unqualified, and resolves through the connection's search_path as it does in plain SQL, or schema.name. A type of the same name in a schema outside the path is ignored, so schema-per-tenant setups can each have their own post_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:

  • pgEnumConvertColumn fails on a column default that PostgreSQL cannot cast to the enum, such as a varchar default. Drop the default first and set it again with $table->pgEnumDefault() in Schema::table().
  • Removing a value is not checked in advance. pgEnumRedefine rolls back when a row or a default holds a removed value. pgEnumRedefineWithDefault replaces the column's default, so only a row can make it roll back. Both then throw Laravel's Illuminate\Database\QueryException.
  • pgEnumRedefineWithDefault refuses 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.
  • pgEnumDrop refuses 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() and Priority::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 name or schema.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 int4 throws notAnEnum.

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' or check (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 [], and toMap() throws a LogicException.

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