laravel-support-db

Documentation

PostgreSQL features for Laravel's schema and query builders. Start with Getting started; reach for Recipes when you have a problem rather than a method in mind.

The shape of it

One live user per address — soft-deleted rows do not collide:

Call
Schema::create('users', static function (Blueprint $table) {
    $table->primaryUUID();
    $table->string('email');
    $table->softDeletes();

    $table->uniquePartial('email')->whereNull('deleted_at');
});
Emits
create table "users" ("id" uuid not null default gen_random_uuid(),
    "email" varchar(255) not null,
    "deleted_at" timestamp(0) without time zone null)

create unique index "users_email_unique" on "users" ("email")
    where ("deleted_at" is null)

alter table "users" add primary key ("id")

Reference

PageCovers
Getting startedRequirements, version floors, install, how the package hooks in, a first migration
ColumnsThe PostgreSQL-only column types, UUID key generation, TOAST compression
IndexesPartial and unique-partial indexes, predicates, modifiers, GIN operator classes
ViewsPlain and materialized views — create, refresh, inspect, drop, other schemas
Schema operationsCREATE TABLE … LIKE / AS TABLE / AS SELECT, DROP … CASCADE, extensions
Query builderRETURNING on UPDATE and DELETE

Practice

PageCovers
RecipesThirteen realistic problems worked end to end
Behaviour notesThe things that are easy to trip over, and what Laravel 13 already does itself
RoadmapWhere the package goes next, and what is deliberately not on the list
Testing & contributingRunning the suite, and the gate a pull request has to pass

At a glance

What this package adds, and where each of them is documented:

FeatureDocumented in
Partial and unique-partial indexes, with predicatesIndexes
CREATE INDEX CONCURRENTLY, NULLS NOT DISTINCT on a partial indexIndexes
GIN indexes with an operator classIndexes
Views, including materialized ones, and schema-qualified lookupsViews
CREATE TABLE … LIKE / AS SELECT / AS TABLESchema operations
DROP TABLE … CASCADESchema operations
CREATE / DROP EXTENSIONSchema operations
UPDATE / DELETE … RETURNINGQuery builder
Column compressionColumns
bit, numeric, xml, cidr, daterange, tsrange, geometry, arraysColumns
UUID primary keys without an extensionColumns

Reference 1

Getting started

Requirements, install, and how the package hooks itself into Laravel.

Requirements

VersionNotes
PHP>= 8.5
Laravel>= 13.0illuminate/database
PostgreSQL13 – 18every one of them is exercised in CI

Two features need a newer server than the 13 floor, and only those two:

FeatureNeeds
compression()PostgreSQL >= 14
nullsNotDistinct()PostgreSQL >= 15

The package targets PostgreSQL and takes effect on pgsql connections only. Other drivers on the same application are untouched.

Installation

composer require efureev/laravel-support-db

It registers itself through package discovery — no provider to add, no config to publish.

On boot it routes pgsql connections to its own connection class through Illuminate\Database\Connection::resolverFor(). DB::connection() then returns Php\Support\Laravel\Database\Schema\Postgres\Connection, Schema:: reaches the extended builder, and the closure in a Schema::create() receives the extended blueprint.

Type-hint the package's Blueprint in migrations to get the additions in your editor:

use Illuminate\Support\Facades\Schema;
use Php\Support\Laravel\Database\Schema\Postgres\Blueprint;

Schema::create('table', static function (Blueprint $table) { /* … */ });

A bundled .meta.php teaches IDEs about the additions on Schema, Blueprint, ColumnDefinition and the query builder, so the framework's own type hints resolve to them too.

Quick start

A migration using several of the additions at once:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Php\Support\Laravel\Database\Schema\Postgres\Blueprint;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('documents', static function (Blueprint $table) {
            $table->primaryUUID();                       // uuid pk, gen_random_uuid()
            $table->generateUUID('tenant_id', null);     // uuid, nullable, no default
            $table->string('slug');
            $table->textArray('tags');                   // text[]
            $table->jsonb('payload');
            $table->tsRange('valid_for');                // tsrange
            $table->text('body')->compression('lz4');    // PostgreSQL >= 14
            $table->timestamps();
            $table->softDeletes();

            // unique per tenant, but only among rows that are still live
            $table->uniquePartial(['tenant_id', 'slug'])->whereNull('deleted_at');

            // containment queries on the jsonb column
            $table->ginIndex('payload', 'documents_payload_gin', 'jsonb_path_ops');

            // tag lookups
            $table->ginIndex('tags');
        });

        Schema::createView(
            'live_documents',
            'select id, tenant_id, slug from documents where deleted_at is null'
        );
    }

    public function down(): void
    {
        Schema::dropViewIfExists('live_documents');
        Schema::dropIfExistsCascade('documents');
    }
};

Reference 2

Columns

The PostgreSQL-only column types, UUID key generation, and TOAST compression.

Column types

Each method returns a ColumnDefinition, so the framework's modifiers (nullable(), default(), index(), comment(), …) chain off it as usual.

MethodColumn typePostgreSQL docs
bit(string $column, int $length)bit(n)Bit string
numeric(string $column, ?int $precision = null, ?int $scale = null)numeric, numeric(p), numeric(p, s)Numeric
dateRange(string $column)daterangeRange types
tsRange(string $column)tsrangeRange types
timestampRange(string $column)tsrange (alias of tsRange)Range types
ipNetwork(string $column)cidrNetwork types
geoPoint(string $column)pointGeometric types
geoPath(string $column)pathGeometric types
xml(string $column)xmlXML
uuidArray(string $column)uuid[]Arrays
textArray(string $column)text[]Arrays
intArray(string $column)integer[]Arrays

numeric() differs from the framework's decimal() in that both arguments are optional — omit them for an unconstrained numeric, which stores any precision:

$table->numeric('amount');          // numeric
$table->numeric('amount', 10);      // numeric(10)
$table->numeric('amount', 10, 2);   // numeric(10, 2)

information_schema normalises some of these on the way back: all three array types report as ARRAY, and numeric(10) reads as numeric(10,0). That is PostgreSQL, not the package — the emitted DDL is exactly what the table above says.

UUID keys

primaryUUID() is a UUID column plus its primary key; generateUUID() is the column alone. Both default to the native gen_random_uuid(), which needs no extension on PostgreSQL 13 and later.

$table->primaryUUID();                // "id" uuid not null default gen_random_uuid() + pk
$table->primaryUUID('uid');           // same, named "uid"
$table->primaryUUID('id', false);     // pk you populate yourself

The second argument decides where the value comes from, and primaryUUID() passes it straight through to generateUUID():

ArgumentResult
true (default)uuid not null default gen_random_uuid()
falseuuid not null — no default, you must supply a value
nulluuid null — nullable, no default
Expressionuuid not null default <expression>
callable(string $column): stringuuid not null default <the string it returns>
use Illuminate\Database\Query\Expression;

$table->generateUUID();                                  // "id", database-generated
$table->generateUUID('cid');                             // "cid", database-generated
$table->generateUUID('tenant_id', null)->index();        // nullable FK column, indexed
$table->generateUUID('external_id', false);              // not null, supplied by the application
$table->generateUUID('id', new Expression('uuid_generate_v4()'));
$table->generateUUID('id', fn (string $c) => "uuid_generate_v5(uuid_ns_url(), '$c')");

The uuid_generate_* family comes from uuid-ossp — call Schema::createExtensionIfNotExists('uuid-ossp') first. The default gen_random_uuid() is built in and needs nothing.

Column compression

PostgreSQL 14 and later can compress TOAST-able columns with either pglz (the historical algorithm) or lz4 (faster, usually a better ratio). Docs.

$table->text('body')->compression('lz4');      // text compression lz4
$table->text('body')->compression();           // defaults to pglz
$table->text('body')->compression('default');  // the server's default_toast_compression

It works on change() too, where PostgreSQL takes it as a separate statement:

$table->text('body')->compression('lz4')->change();
// alter table "docs" alter column "body" set compression lz4

Only the new rows are compressed with the new method; existing ones keep whatever they were written with until they are rewritten.

Reference 3

Indexes

Partial and unique-partial indexes, the predicates that define them, the modifiers that change how they are built, and GIN operator classes.

Partial indexes

A partial index covers only the rows matching a predicate. It is smaller, cheaper to maintain, and — in the unique case — the standard way to express "unique among the rows that count".

$table->partial('code')->whereNull('deleted_at');
// create index "docs_code_partial" on "docs" ("code") where ("deleted_at" is null)

$table->uniquePartial('email')->whereNull('deleted_at');
// create unique index "docs_email_unique" on "docs" ("email") where ("deleted_at" is null)

Both accept the same three arguments and both take one column or several:

partial(array|string $columns, ?string $index = null, ?string $algorithm = null)
uniquePartial(array|string $columns, ?string $index = null, ?string $algorithm = null)

Without an explicit name the index is called {table}_{columns}_partial or {table}_{columns}_unique, following the framework's own convention.

Dropping. Pass the columns to let the name be derived, or the index name itself if you chose one:

$table->dropPartial(['code']);              // drop index "docs_code_partial"
$table->dropUniquePartial(['email']);       // drop index "docs_email_unique"
$table->dropPartial('docs_reachable_ix');   // drop index "docs_reachable_ix"

dropUnique() does not work on a partial unique index. PostgreSQL has no partial UNIQUE constraint, so uniquePartial() creates a plain unique index with no constraint attached, and there is nothing for ALTER TABLE … DROP CONSTRAINT to find.

Index predicates

The predicate is built fluently. Every method also takes a trailing $boolean, and each has an or spelling so a disjunction reads as one.

MethodEmits
where($column, $operator, $value)("size" > 10)
whereRaw($sql, $bindings = [])(lower(code) = 'x')
whereBool($column, $value)("published" is true)
whereTrue($column) / whereFalse($column)("published" is true) / … is false
whereNull($column) / whereNotNull($column)("deleted_at" is null) / … is not null
whereColumn($first, $operator, $second)("created_at" < "updated_at")
whereIn($column, $values) / whereNotIn(…)("state" in ('a','b')) / … not in (…)
whereBetween($column, [$from, $to]) / whereNotBetween(…)("n" between 1 and 9) / … not between …

Each has an orWhere… counterpart: orWhere, orWhereRaw, orWhereBool, orWhereTrue, orWhereFalse, orWhereNull, orWhereNotNull, orWhereColumn, orWhereIn, orWhereNotIn, orWhereBetween, orWhereNotBetween.

$table->partial('code', 'docs_reachable')
    ->whereNull('deleted_at')
    ->orWhereTrue('is_pinned')
    ->whereFalse('is_draft');
// … where ("deleted_at" is null) or ("is_pinned" is true) and ("is_draft" is false)

A leading or is stripped, so the first predicate may use either spelling.

Values. Accepted: string, int, float, bool, null, BackedEnum (its value is used), DateTimeInterface, and Stringable. They are escaped and inlined — an index predicate is part of the DDL, and PostgreSQL takes no parameters there. Anything else is rejected rather than coerced.

$table->partial('state')->where('state', '=', OrderState::Paid);   // backed enum
$table->partial('at')->where('at', '>', new DateTimeImmutable('2026-01-01'));

A DateTimeInterface is rendered with format('Y-m-d H:i:s') — local wall-clock time, with no offset. Against a timestamptz column PostgreSQL then reads it in the server's TimeZone, so pass a value already in the server's zone, or write the predicate with whereRaw() and spell the offset out yourself.

whereRaw() uses ? placeholders, bound the same way and inlined the same way:

$table->partial('code')->whereRaw('lower(code) = ?', ['abc']);
// … where (lower(code) = 'abc')

Index modifiers

These chain onto partial() and uniquePartial() in any position — before or after the predicates, it makes no difference.

ModifierEffect
algorithm(string $method)using gin — the access method; also the third constructor argument
online(bool $value = true)create index concurrently — builds without a write lock
nullsNotDistinct(bool $value = true)nulls not distinct — unique only, PostgreSQL >= 15
$table->partial('tags', 'docs_tags_gin', 'gin')->whereNotNull('tags');
// identical:
$table->partial('tags', 'docs_tags_gin')->algorithm('gin')->whereNotNull('tags');
$table->partial('code')->whereNull('deleted_at')->online();
// create index concurrently "docs_code_partial" on "docs" ("code") where ("deleted_at" is null)
$table->uniquePartial(['team', 'seat'])->nullsNotDistinct()->whereNull('deleted_at');
// create unique index "docs_team_seat_unique" on "docs" ("team", "seat")
//     nulls not distinct where ("deleted_at" is null)

online() needs $withinTransaction = false. PostgreSQL refuses CREATE INDEX CONCURRENTLY inside a transaction block, and a PostgreSQL migration runs in one by default — Migration::$withinTransaction is true and the Postgres grammar reports that it supports schema transactions, so the migrator wraps it. Set public $withinTransaction = false; on the migration, or the statement aborts.

nullsNotDistinct() is unique-only. PostgreSQL parses the clause on a non-unique index and then ignores it, so partial() throws rather than emitting something that does nothing.

UNIQUE is btree-only. PostgreSQL supports unique indexes on btree alone, so an algorithm() other than btree on uniquePartial() is rejected by the server.

Covering indexes and column expressions

include() carries extra columns in the index leaf without indexing them, so a query that reads only those never touches the table (PostgreSQL 11 and later):

Call
$table->partial('room_id', 'bookings_cover')
    ->include(['during', 'guest'])
    ->whereNull('cancelled_at');
Emits
create index "bookings_cover" on "bookings" ("room_id")
    include ("during", "guest") where ("cancelled_at" is null)

Everything else PostgreSQL allows in an index column list — an expression, a sort direction, where nulls sort, a per-column operator class — is reachable by passing an Expression instead of a name:

Call
use Illuminate\Database\Query\Expression;

$table->partial([new Expression('lower(email)')], 'users_lower_email')->whereNull('deleted_at');
$table->partial([new Expression('created_at desc nulls last'), 'id'], 'docs_recent')->whereTrue('live');
$table->partial([new Expression('payload jsonb_path_ops')], 'docs_payload', 'gin')->whereNotNull('payload');
Emits
create index "users_lower_email" on "users" (lower(email)) where ("deleted_at" is null)
create index "docs_recent" on "docs" (created_at desc nulls last, "id") where ("live" is true)
create index "docs_payload" on "docs" using gin (payload jsonb_path_ops) where ("payload" is not null)

An Expression is emitted verbatim — it is not quoted and not escaped. Build it from literals in your own code, never from user input.

GIN indexes and operator classes

ginIndex() is a shorthand for a GIN index — the access method for arrays, jsonb and full-text search.

$table->textArray('tags');
$table->ginIndex('tags');
// create index "docs_tags_index" on "docs" using gin ("tags")

A third argument names an operator class, which is where GIN indexes earn their keep:

ginIndex(array|string $columns, ?string $name = null, ?string $operatorClass = null)
$table->jsonb('payload');
$table->ginIndex('payload', 'docs_payload_gin', 'jsonb_path_ops');
// create index "docs_payload_gin" on "docs" using gin ("payload" jsonb_path_ops)
Operator classForBuys you
jsonb_path_opsjsonbA smaller, faster index for containment (@>) — at the cost of key-existence operators
gin_trgm_opstext (needs pg_trgm)Indexed LIKE '%…%' and similarity search
array_opsarraysThe default; rarely named explicitly

The framework accepts an operator class on spatial and vector indexes only — its index() takes no such argument, and compileIndex() discards one that arrives another way.

Reference 4

Views

Plain and materialized views — creating, refreshing, inspecting and dropping them, including in another schema.

Call
Schema::createView('active_users', 'select id, email from users where is_active');
Schema::createViewOrReplace('active_users', 'select id, email, role from users where is_active');

// third argument makes it MATERIALIZED
Schema::createView('order_totals', 'select user_id, sum(total) from orders group by 1', true);
Emits
create view "active_users" as select id, email from users where is_active
create or replace view "active_users" as select id, email, role from users where is_active
create materialized view "order_totals" as select user_id, sum(total) from orders group by 1

The same methods exist on the blueprint, if you would rather create a view alongside its table:

Schema::table('users', static function (Blueprint $table) {
    $table->createView('active_users', 'select id from users where is_active');
});

Refreshing a materialized view.

Call
Schema::refreshMaterializedView('order_totals');
Schema::refreshMaterializedView('order_totals', true);   // CONCURRENTLY
Emits
refresh materialized view "order_totals"
refresh materialized view concurrently "order_totals"

CONCURRENTLY keeps the view readable while it rebuilds, but PostgreSQL requires the view to carry a unique index and to have been populated at least once. It is also refused inside a transaction block.

Inspecting. Both of these see materialized views, which the framework's own hasView() does not — it reads pg_views, where materialized views do not appear.

Schema::hasView('active_users');            // bool
Schema::getViewDefinition('active_users');  // the SELECT, or '' if there is no such view

Other schemas. Every view method takes a schema.view reference, the lookups included:

Schema::createView('reporting.active_users', 'select id from users where is_active');

Schema::hasView('reporting.active_users');           // true
Schema::hasView('active_users');                     // false — a different view
Schema::getViewDefinition('reporting.active_users');
Schema::dropView('reporting.active_users');

Without a schema the connection's own is used. A three-part reference is rejected.

Dropping. A materialized view must be dropped as such — DROP VIEW fails on one:

Schema::dropView('active_users');
Schema::dropViewIfExists('active_users');

Schema::dropView('order_totals', true);          // drop materialized view
Schema::dropViewIfExists('order_totals', true);

Reference 5

Schema operations

Creating a table from another, dropping with dependents, and managing extensions.

Creating a table from another

Three PostgreSQL forms, three different things copied.

like() — structure, no data. The closest thing to a template.

Schema::create('users_archive', static function (Blueprint $table) {
    $table->like('users')->includingAll();
    $table->ifNotExists();
});
// create table if not exists "users_archive" (like "users" including all)

includingAll() is PostgreSQL's shorthand for INCLUDING DEFAULTS, CONSTRAINTS, INDEXES, STORAGE, COMMENTS. Drop it to copy the column definitions alone.

fromTable() — columns and data, nothing else. No indexes, no constraints, no defaults.

Schema::create('users_snapshot', static function (Blueprint $table) {
    $table->fromTable('users');
});
// create table "users_snapshot" as table "users"

fromSelect() — whatever the query returns.

Schema::create('active_snapshot', static function (Blueprint $table) {
    $table->fromSelect('select id, email from users where is_active');
});
// create table "active_snapshot" as (select id, email from users where is_active)

The query is arbitrary, so this is also how you reshape on the way:

Schema::create('users_reindexed', static function (Blueprint $table) {
    $table->fromSelect('select gen_random_uuid() as id, email, created_at from users');
});

ifNotExists() adds IF NOT EXISTS to any Schema::create(), with or without the above.

Dropping with CASCADE

Drops the table and everything depending on it — views, foreign keys, sequences, and whatever depends on those in turn.

Call
Schema::dropIfExistsCascade('users');
Emits
drop table if exists "users" cascade

Without it, PostgreSQL refuses to drop a table a view is built on. Useful in down() and in test teardown; deliberate everywhere else, since the blast radius is by definition not local.

Extensions

Call
Schema::createExtension('uuid-ossp');             // fails if it is already there
Schema::createExtensionIfNotExists('uuid-ossp');  // idempotent

Schema::dropExtensionIfExists('tablefunc');
Schema::dropExtensionIfExists('tablefunc', 'fuzzystrmatch');   // several at once
Emits
create extension "uuid-ossp"
create extension if not exists "uuid-ossp"

drop extension if exists "tablefunc"
drop extension if exists "tablefunc", "fuzzystrmatch"

Creating an extension usually needs a superuser or an explicitly trusted extension.

Types of your own

Laravel can read a user-defined type back and drop every one at once, but has no way to create a single one.

Enums. A label set the database itself enforces, ordered the way you declare it:

Call
Schema::createEnumType('order_state', ['new', 'paid', 'shipped']);
Emits
create type "order_state" as enum ('new', 'paid', 'shipped')

The type is then an ordinary column type — $table->rawColumn('state', 'order_state') — and PostgreSQL refuses any label it does not know.

Labels are added to an existing type, optionally positioned. The order matters: comparisons and ORDER BY follow it, not the alphabet.

Schema::addEnumValue('order_state', 'refunded');            // at the end
Schema::addEnumValue('order_state', 'pending', 'new');      // before 'new'
Schema::addEnumValue('order_state', 'in_transit', after: 'paid');

PostgreSQL 12 and later allow this inside a transaction, so an ordinary migration can do it — as long as the new label is not also used in that same transaction.

Domains. A base type with a rule attached, so the rule lives with the type instead of being repeated on every column that uses it:

Call
use Php\Support\Laravel\Database\Schema\Postgres\Builders\Indexes\PartialBuilder;

Schema::createDomain('positive_int', 'integer', fn (PartialBuilder $c) => $c->where('value', '>', 0));
Schema::createDomain('score', 'integer', fn (PartialBuilder $c) => $c->where('value', '>=', 0)->where('value', '<=', 100));
Emits
create domain "positive_int" as integer check (("value" > 0))

The predicate names value — PostgreSQL calls the thing being checked VALUE, and resolves a quoted "value" to it, so the vocabulary from Index predicates reaches here unchanged. The catalogue stores it as CHECK ((VALUE > 0)).

Composites. Several fields under one name:

Call
Schema::createCompositeType('full_name', ['first' => 'text', 'last' => 'varchar(30)']);
Emits
create type "full_name" as ("first" text, "last" varchar(30))

Dropping. Types and domains are dropped separately, several at a time, and a name may be schema-qualified:

Schema::dropTypeIfExists('order_state', 'full_name');
Schema::dropDomainIfExists('positive_int');
Schema::dropTypeIfExistsCascade('order_state');   // and every column using it

Values and base type names are interpolated into DDL — PostgreSQL takes no parameter there — so a value is escaped and a type name is checked against what a type name may look like.

Exclusion constraints

A unique index says two rows must not be equal. An exclusion constraint says they must not overlap, intersect, or whatever else an operator expresses — which is what a range column exists for, and which Laravel has no form for:

Call
Schema::createExtensionIfNotExists('btree_gist');

Schema::create('bookings', static function (Blueprint $table) {
    $table->increments('id');
    $table->integer('room_id');
    $table->tsRange('during');
    $table->timestamp('cancelled_at')->nullable();

    $table->exclusion('bookings_no_overlap')
        ->using('gist')
        ->with('room_id', '=')
        ->with('during', '&&')
        ->whereNull('cancelled_at');
});
Emits
alter table "bookings" add constraint "bookings_no_overlap"
    exclude using gist ("room_id" with =, "during" with &&)
    where ("cancelled_at" is null)

No two live bookings for the same room may overlap; a cancelled one is outside the constraint and may overlap anything. with() accumulates, and the predicate takes the whole vocabulary from Index predicates.

A range operator needs gist. The default access method is btree, which supports only =; && against it fails with operator &&(anyrange,anyrange) is not a member of operator family. Mixing a scalar column into the same constraint additionally needs btree_gist, since gist alone cannot index an integer.

The operator is checked against the character set PostgreSQL builds operator names from, because it is interpolated into DDL and cannot be bound.

Dropping works for any named constraint:

$table->dropConstraint('bookings_no_overlap');
$table->dropCheck('price_positive');          // the same statement, named for what it drops

Check constraints

Laravel's schema builder has no CHECK in any grammar, so a table's columns can be described and most of its invariants cannot. The condition is written with the same vocabulary as a partial index — PostgreSQL treats both as a boolean expression over a row:

Call
Schema::create('products', static function (Blueprint $table) {
    $table->integer('price');
    $table->string('state');

    $table->check('price_positive')->where('price', '>', 0);
    $table->check('state_known')->whereIn('state', ['new', 'paid', 'shipped']);
});
Emits
alter table "products" add constraint "price_positive" check (("price" > 0))
alter table "products" add constraint "state_known" check (("state" in ('new','paid','shipped')))

Each is emitted as its own ALTER TABLE, so the same call works on a table being created and on one that already exists. Dropping takes the constraint name:

Call
Schema::table('products', static fn (Blueprint $table) => $table->dropCheck('price_positive'));
Emits
alter table "products" drop constraint "price_positive"

Every predicate from Index predicates is available. A check with no conditions is refused rather than compiled — check (()) is a syntax error.

Partitioning

A partitioned table holds no rows; its partitions do, and PostgreSQL routes every insert to the right one. Laravel has no form for any of it.

Call
Schema::create('events', static function (Blueprint $table) {
    $table->bigInteger('id');
    $table->timestamp('at');
    $table->primary(['id', 'at']);

    $table->partitionBy('range', 'at');      // or 'list', or 'hash'
});
Emits
create table "events" ("id" bigint not null, "at" timestamp(0) without time zone not null,
    primary key ("id", "at")) partition by range ("at")

The primary key must contain every partitioning column. PostgreSQL requires it and says so obscurely — unique constraint on partitioned table must include all partitioning columns. The pair that trips it is bigIncrements('id') beside partitionBy('range', 'at'), which looks perfectly ordinary. This package checks first and names the actual problem, so partition by a column the key already has, or widen the key as above.

A partition takes no column list of its own — it inherits the parent's:

Call
Schema::create('events_2026', fn (Blueprint $t) => $t->partitionOf('events')->fromTo('2026-01-01', '2027-01-01'));
Schema::create('events_rest', fn (Blueprint $t) => $t->partitionOf('events')->asDefault());

Schema::create('logs_eu',   fn (Blueprint $t) => $t->partitionOf('logs')->in(['de', 'fr']));
Schema::create('shards_0',  fn (Blueprint $t) => $t->partitionOf('shards')->hash(modulus: 4, remainder: 0));
Emits
create table "events_2026" partition of "events" for values from ('2026-01-01') to ('2027-01-01')
create table "events_rest" partition of "events" default
create table "logs_eu" partition of "logs" for values in ('de', 'fr')
create table "shards_0" partition of "shards" for values with (modulus 4, remainder 0)

An existing table can be adopted, and a partition released back into one of its own — the rows go with it:

Schema::table('events', static function (Blueprint $table) {
    $table->attachPartition('events_2025')->fromTo('2025-01-01', '2026-01-01');
    $table->detachPartition('events_2024');
    $table->detachPartition('events_2023', concurrently: true);
});

detachPartition(concurrently: true) avoids the access-exclusive lock and, like every CONCURRENTLY in PostgreSQL, cannot run inside a transaction block — see Behaviour notes.

Row-level security

Per-row authorisation the database enforces itself, which no amount of application code can be talked out of. It pairs with the schema-qualified views above for multi-tenant data.

Call
Schema::create('documents', static function (Blueprint $table) {
    $table->increments('id');
    $table->string('tenant');
    $table->text('body');

    $table->enableRowLevelSecurity();
    $table->forceRowLevelSecurity();        // the owner obeys the policies too

    $table->policy('tenant_read')
        ->for('select')
        ->to('app_user')
        ->using(fn (PartialBuilder $w) => $w->whereRaw('tenant = current_setting(?, true)', ['app.tenant']));

    $table->policy('tenant_write')
        ->for('insert')
        ->to('app_user')
        ->withCheck(fn (PartialBuilder $w) => $w->whereRaw('tenant = current_setting(?, true)', ['app.tenant']));
});
Emits
alter table "documents" enable row level security
alter table "documents" force row level security
create policy "tenant_read" on "documents" for select to "app_user"
    using ((tenant = current_setting('app.tenant', true)))
create policy "tenant_write" on "documents" for insert to "app_user"
    with check ((tenant = current_setting('app.tenant', true)))

using decides which rows a statement may see; withCheck decides which it may leave behind. Both take the predicate vocabulary from Index predicates, and a policy with neither is refused — it would permit nothing, which is a mistake more often than an intention.

$table->dropPolicy('tenant_read');
$table->disableRowLevelSecurity();

Switching security on hides every row until a policy permits one. Without force, the table owner bypasses the policies entirely — which is easy to miss when testing as the owner.

How a table is stored

Call
Schema::create('cache', static function (Blueprint $table) {
    $table->string('key');

    $table->unlogged();                                    // no write-ahead log
    $table->storageParameters(['fillfactor' => 70, 'autovacuum_vacuum_scale_factor' => 0.05]);
});

Schema::table('cache', fn (Blueprint $t) => $t->resetStorageParameters('fillfactor'));
Emits
create unlogged table "cache" ("key" varchar(255) not null)
alter table "cache" set (fillfactor = 70, autovacuum_vacuum_scale_factor = 0.05)
alter table "cache" reset (fillfactor)

An unlogged table is faster to write and is emptied after a crash and never replicated. For data you can rebuild, and nothing else. temporary() and unlogged() are mutually exclusive; temporary wins.

Storage parameters are their own ALTER TABLE, so the same call works on a new table and on one that already exists. Both the name and the value are checked, since neither can be a bound parameter.

Extended statistics

The planner assumes columns are independent. When they are not — a city that implies its country, a status that implies its type — it multiplies the two selectivities and lands orders of magnitude off, and an estimate that wrong usually picks the wrong plan.

Call
Schema::create('events', static function (Blueprint $table) {
    $table->string('kind');
    $table->string('region');

    $table->statistics('events_kind_region')->on('kind', 'region');
});
Emits
create statistics "events_kind_region" on "kind", "region" from "events"

That is measurable rather than theoretical. Over a table where kind and region agree exactly, explain estimates about a ninth of the rows before the statistics exist and about a third after — which is the correct answer, and the package's own test asserts the improvement.

KindWhat it records
ndistincthow many distinct combinations the columns have together
dependenciesthat one column's value implies another's
mcvthe commonest combinations, with their frequencies

All three are collected unless you name fewer:

$table->statistics('s')->on('kind', 'region')->kinds('ndistinct', 'dependencies');
$table->statistics('s')->ifNotExists()->on('kind', 'region');
$table->dropStatistics('s', 'other');

At least two columns. PostgreSQL refuses one, because a single column's distribution is what it already gathers by itself — the package says so before the statement is sent.

An Expression may stand in for a column, which needs PostgreSQL 14; before that the server accepts only plain column references.

Statistics are gathered by ANALYZE, so a fresh object tells the planner nothing until the next analyze — automatic or otherwise.

Reference 6

Query builder

RETURNING on UPDATE and DELETE — the rows a write touched, in one round trip.

RETURNING on UPDATE and DELETE

PostgreSQL can hand back the rows a write touched. One round trip instead of select-then-write, and — unlike reading first — no window in which another transaction changes the set underneath you.

// on the query builder
$updated = DB::table('orders')
    ->where('state', 'new')
    ->updateAndReturn(['state' => 'paid'], 'id', 'total');

$deleted = DB::table('sessions')
    ->where('expires_at', '<', now())
    ->deleteAndReturn('id', 'user_id');
// on Eloquent, via macros
$updated = Order::where('state', 'new')->updateAndReturn(['state' => 'paid'], 'id');
$deleted = Order::where('state', 'cancelled')->deleteAndReturn('id');

Both take the columns as a variadic list; pass * for the whole row.

Eloquent versus toBase(). Through Eloquent the model's updated_at is maintained as usual. Model::toBase() drops to the query builder and writes exactly the columns you pass — which is what you want for a sweep that should not touch timestamps.

Rows come back in the connection's configured fetch mode, the same as DB::select()stdClass objects unless you have changed it, and a StatementPrepared event is dispatched either way.

Inserting and reading back

insertGetId() returns one key of one row. insertAndReturn() returns whatever columns you name, for every row inserted — which is the only way to read back what the database generated for a batch:

Call
$rows = DB::table('orders')->insertAndReturn(
    [['total' => 20], ['total' => 30]],
    'id',
    'created_at'
);
Emits
insert into "orders" ("total") values (?), (?) returning "id","created_at"

Pass * for whole rows. A single row may be given unwrapped, exactly as insert() allows, and each row's keys are sorted so a batch lines up.

Naming no column emits no RETURNING, and PostgreSQL then answers with one column-less row per row affected. That is what updateAndReturn() and deleteAndReturn() have always done, and the three do not disagree.

Upserting against a partial unique index

A partial unique index is the package's answer to "unique among the rows that count", and it makes the framework's upsert() unusable on its own:

Call
$table->uniquePartial('email')->whereNull('deleted_at');

DB::table('users')->upsert([['email' => 'a@x.io', 'name' => 'B']], ['email'], ['name']);
Emits
SQLSTATE[42P10]: there is no unique or exclusion constraint
matching the ON CONFLICT specification

PostgreSQL will not infer a partial index from the conflict columns alone — the index predicate has to be repeated, and has to match. onConflictWhere() supplies it, in the same vocabulary the index was declared with:

Call
use Php\Support\Laravel\Database\Schema\Postgres\Builders\Indexes\PartialBuilder;

DB::table('users')
    ->onConflictWhere(static fn (PartialBuilder $where) => $where->whereNull('deleted_at'))
    ->upsert([['email' => 'a@x.io', 'name' => 'B']], ['email'], ['name']);
Emits
insert into "users" ("email", "name") values (?, ?)
    on conflict ("email") where ("deleted_at" is null)
    do update set "name" = "excluded"."name"

Every predicate from Index predicates works here, because it is the same builder. Repeated calls accumulate rather than replace.

The predicate must match the index, not merely be true. PostgreSQL compares the two and refuses a conflict target it cannot map onto an existing index — a predicate of whereNotNull('deleted_at') against an index built where deleted_at is null fails exactly as an absent one does. Write the same predicate in both places.

Practice

Recipes

Realistic problems worked end to end. Every one of these was executed against PostgreSQL 18 before it was published.

Unique among live rows only

The classic soft-delete problem: unique on email stops a user from ever re-registering with an address that was once deleted.

Schema::create('users', static function (Blueprint $table) {
    $table->primaryUUID();
    $table->string('email');
    $table->softDeletes();

    $table->uniquePartial('email')->whereNull('deleted_at');
});

Two rows with the same address are now fine as long as at most one is live.

One default per group

"Exactly one primary address per user", "one active price per product" — a unique index over the grouping column, restricted to the rows that claim the role.

Schema::create('addresses', static function (Blueprint $table) {
    $table->increments('id');
    $table->foreignId('user_id');
    $table->boolean('is_primary')->default(false);

    $table->uniquePartial('user_id', 'addresses_one_primary')->whereTrue('is_primary');
});

Any number of non-primary addresses, never two primary ones.

At most one NULL

Nulls are distinct by default, so a plain unique index lets any number of them through. nullsNotDistinct() reverses that — useful when null means something specific, such as "the unassigned seat".

Schema::create('memberships', static function (Blueprint $table) {
    $table->increments('id');
    $table->string('team');
    $table->string('seat')->nullable();
    $table->softDeletes();

    $table->uniquePartial(['team', 'seat'], 'memberships_seat_unique')
        ->nullsNotDistinct()
        ->whereNull('deleted_at');
});

PostgreSQL >= 15.

Tag search over a text array

Schema::create('posts', static function (Blueprint $table) {
    $table->increments('id');
    $table->textArray('tags');

    $table->ginIndex('tags');
});
Post::whereRaw("tags @> ARRAY[?]::text[]", ['postgres'])->get();

Containment queries on jsonb

Schema::create('events', static function (Blueprint $table) {
    $table->primaryUUID();
    $table->jsonb('payload');

    $table->ginIndex('payload', 'events_payload_gin', 'jsonb_path_ops');
});
Event::whereRaw("payload @> ?::jsonb", [json_encode(['type' => 'signup'])])->get();

jsonb_path_ops indexes only containment, which is what makes it smaller and faster than the default. If you also need ? key-existence operators, leave the operator class off.

Fuzzy title search

Schema::createExtensionIfNotExists('pg_trgm');

Schema::create('articles', static function (Blueprint $table) {
    $table->increments('id');
    $table->string('title');

    $table->ginIndex('title', 'articles_title_trgm', 'gin_trgm_ops');
});
Article::where('title', 'ilike', '%postgres%')->get();   // now indexable

Adding an index to a busy table

An ordinary CREATE INDEX holds a write lock for its duration. online() trades a second pass for not blocking writes.

use Illuminate\Database\Migrations\Migration;

return new class extends Migration {
    // a PostgreSQL migration is wrapped in a transaction by default, and
    // CREATE INDEX CONCURRENTLY cannot run inside one
    public $withinTransaction = false;

    public function up(): void
    {
        Schema::table('orders', static function (Blueprint $table) {
            $table->partial('customer_id', 'orders_open_customer')
                ->whereIn('state', ['new', 'paid'])
                ->online();
        });
    }
};

A reporting view refreshed without downtime

Schema::createView(
    'order_totals',
    'select customer_id, count(*) as orders, sum(total) as revenue from orders group by 1',
    true
);

// CONCURRENTLY needs a unique index on the view
DB::statement('create unique index order_totals_customer on order_totals (customer_id)');
Schema::refreshMaterializedView('order_totals', true);

Readers keep seeing the previous contents while the refresh runs.

Snapshot a table three ways

// structure, indexes, defaults and constraints — no rows
Schema::create('orders_template', static function (Blueprint $table) {
    $table->like('orders')->includingAll();
});

// rows and columns — nothing else
Schema::create('orders_backup', static function (Blueprint $table) {
    $table->fromTable('orders');
});

// only what the query selects
Schema::create('orders_2026', static function (Blueprint $table) {
    $table->fromSelect("select * from orders where created_at >= '2026-01-01'");
});

A sweep that reports what it touched

$expired = DB::table('sessions')
    ->where('last_seen_at', '<', now()->subDays(30))
    ->deleteAndReturn('id', 'user_id');

foreach ($expired as $session) {
    SessionExpired::dispatch($session->user_id);
}

One statement, and the rows come back as they were — no select-then-delete race.

The same for a state transition that has to notify:

$paid = Order::where('state', 'authorised')
    ->where('authorised_at', '<', now()->subMinutes(15))
    ->updateAndReturn(['state' => 'captured'], 'id', 'customer_id', 'total');

UUID keys without an extension

Schema::create('documents', static function (Blueprint $table) {
    $table->primaryUUID();                        // default gen_random_uuid()
    $table->generateUUID('parent_id', null);      // nullable reference
});

Schema::table('documents', static function (Blueprint $table) {
    $table->foreign('parent_id')->references('id')->on('documents');
});

gen_random_uuid() is built into PostgreSQL 13 and later — no uuid-ossp, no application-side generation.

The self-reference is added in a second step on purpose. primaryUUID() emits its primary key as a trailing ALTER TABLE, which Laravel orders after the foreign keys of the same blueprint, so declaring both together gives there is no unique constraint matching given keys. Splitting the statements avoids it; so does declaring $table->primary('id') explicitly before the foreign() call.

If your ids come from the application instead:

$table->primaryUUID('id', false);   // uuid not null, no default

Per-tenant views in their own schema

A view definition takes no bindings — PostgreSQL stores it as text — so the tenant's schema name and id go into the statement literally. Validate the identifier and quote the literal yourself; this is the one place in the package's surface where you have to:

use Illuminate\Support\Facades\DB;

foreach ($tenants as $tenant) {
    // an identifier is interpolated, never bound: allow only what an identifier may contain
    if (! preg_match('/^[a-z_][a-z0-9_]*$/', $tenant->schema)) {
        throw new InvalidArgumentException("Refusing schema name [{$tenant->schema}].");
    }

    // a literal is escaped the way PostgreSQL expects, doubling any quote
    $id = "'" . str_replace("'", "''", $tenant->id) . "'";

    DB::statement("create schema if not exists {$tenant->schema}");

    Schema::createView(
        "{$tenant->schema}.active_users",
        "select id, email from users where tenant_id = {$id} and deleted_at is null"
    );
}

Schema::hasView("{$tenants[0]->schema}.active_users");   // true

The package applies the same two rules internally — WheresBuilder::quoteLiteral() doubles quotes, and the index algorithm and compression method are matched against an identifier pattern before they reach the DDL. A schema name arriving from a tenants table deserves no less.

Dropping a table other objects depend on

public function down(): void
{
    Schema::dropViewIfExists('order_totals', true);
    Schema::dropIfExistsCascade('orders');
}

dropIfExistsCascade() takes the dependents with it, which is what makes a down() reliable when views were created over the table.

Practice

Behaviour notes

The things that are easy to trip over, and what the framework already does without this package.

Things that are easy to trip over, gathered in one place.

Server version floors. compression() needs PostgreSQL 14, nullsNotDistinct() needs 15. Everything else works from 13. All six versions run in CI.

CONCURRENTLY cannot run in a transaction. That applies to online() on an index and to refreshMaterializedView($view, true). A PostgreSQL migration runs inside a transaction by default — Migration::$withinTransaction is true, and the Postgres grammar reports that it supports schema transactions, so the migrator wraps it — which means both statements abort unless the migration sets public $withinTransaction = false;.

Partial unique indexes are indexes, not constraints. PostgreSQL has no partial UNIQUE constraint, so use dropUniquePartial()dropUnique() will not find anything to drop.

Identifiers are quoted, so case is preserved. createView('MyView', …) creates a view named MyView, and hasView('MyView') is what finds it. This differs from the framework's hasView(), which folds case on both sides.

RETURNING respects the fetch mode. Rows come back the way DB::select() would return them, and a StatementPrepared event is dispatched, so anything you have hooked onto that still fires.

The driver resolver is process-wide. The package registers itself for pgsql through Connection::resolverFor(), which the framework's connection factory consults first. If another package resolves the same driver, the last registration wins.

Index predicates are DDL. PostgreSQL takes no parameters in a WHERE on an index, so values are escaped and inlined rather than bound. Only the documented value types are accepted; anything else is rejected rather than guessed at.

Already in Laravel 13

Some of what this package once existed for now ships with the framework. Reach for these first — they are not duplicated here:

FeatureNative form
Unique index options$table->unique($cols)->nullsNotDistinct()->deferrable()->initiallyImmediate()
Index without locking the table$table->index($cols)->online()
Index access method$table->index($cols, $name, 'gin')
Arbitrary column type$table->rawColumn('c', 'tstzrange')
Vector and full-text$table->vector('embedding', 3), $table->vectorIndex('embedding'), $table->tsvector('doc')
Table and column comments$table->comment('…'), $table->string('c')->comment('…')

What this package adds on top is everything partial — the index variants Laravel has no form for — plus views, the CREATE TABLE variants, extensions, RETURNING, and the column types above.

Practice

Roadmap

Where the package goes next, and why in that order. Every gap below was checked against Laravel 13 and against a running PostgreSQL before it was written down — what the framework already does is listed too, so none of it gets built twice.

The shape of the work

The package exists to say the PostgreSQL things Laravel's builder cannot. Everything planned here has shipped: the missing halves of what had already been sold, the constraint that tsrange and daterange exist for, types of one's own, and the physical layout of a table.

Nothing is outstanding. What follows is the record of how it went — including the two entries this page got wrong, which are corrected in place rather than quietly dropped.

v5.1 — shipped

Every item of this group is done: onConflictWhere() and insertAndReturn() under Query builder, and check() under Schema operations.

v5.2 — shipped

Exclusion constraints are under Schema operations; covering indexes and the Expression route for column expressions, ordering and per-column operator classes are under Indexes.

Three of the four "index expressiveness" items turned out to need no new API at all: an Expression in the column list already reaches PostgreSQL verbatim, so an expression index, a sort direction and a per-column operator class were all expressible before this release and simply undocumented. This list said "nothing, anywhere" for them, and that was wrong. Only INCLUDE was genuinely missing.

v5.3 — shipped

Enum types, domains and composite types are under Schema operations, together with adding a label to an existing enum and dropping either kind.

v5.4 — shipped, and not the major it was planned as

Partitioning, row-level security, unlogged tables and storage parameters are under Schema operations.

This group was written down as v6 because it looked like the one that would break something. It does not: every item turned out to be a new method beside the existing ones, and the only existing code touched was the create compiler, which gained two clauses. So it shipped as a minor, and the version number here is corrected rather than kept for appearance.

CREATE STATISTICS shipped with it after all — see Extended statistics.

Deliberately not on this list

Checked as native in Laravel 13, so building them here would be the duplication this package spent a major version removing:

Already nativeUse
Generated columns$table->integer('c')->storedAs('a + b'), ->virtualAs(…)
DISTINCT ON$query->distinct('column') on a pgsql connection
Lateral joins$query->joinLateral(…)
Vector and full-textvector(), vectorIndex(), tsvector()
Comments$table->comment(…), $column->comment(…)
Arbitrary column type$table->rawColumn('c', 'tstzrange')
Unique index options->nullsNotDistinct(), ->deferrable(), ->online()

Common table expressions are not in Laravel, and are still not for this package: WITH is ordinary SQL rather than a PostgreSQL extension, so it belongs to a cross-database package.

How this is sequenced

VersionCarriesBreaks
v5.1shipped — ON CONFLICT with a predicate, insertAndReturn(), check()nothing
v5.2shipped — exclusion constraints, covering indexesnothing
v5.3shipped — enum types, domains, composite typesnothing
v5.4shipped — partitioning, RLS, unlogged, storage parameters, extended statisticsnothing

Nothing on this page broke anything, so every group shipped as a minor.

Each item wants the same treatment as the rest of the package: a test that fails without it, the emitted SQL asserted exactly, and behaviour checked against a real server rather than recalled. See Testing & contributing.

Practice

Testing & contributing

Running the suite, and the gate a pull request has to pass.

Testing

The package targets PostgreSQL, so the functional suite needs a running server.

With Docker — nothing local required:

composer test:docker

# both versions are overridable
POSTGRES_VERSION=15 composer test:docker
PHP_VERSION=8.5 composer test:docker

Locally — connection settings come from the environment, defaulting to forge/forge/forge on localhost:5432:

composer test         # PHPCS + the whole suite
composer test-cover   # with coverage
composer phpunit-unit # the unit suite alone — asserts on generated SQL, needs no database

The unit suite asserts on generated SQL and never opens a connection, so it runs anywhere. The functional suite reads the catalogue back from a real server, which is the only way some of these features can be checked at all.

Contributing

Issues and pull requests are welcome. Before opening one, run the gate CI runs:

composer phpcs      # PSR-12 over src and tests
composer phpstan    # level 6 with larastan
composer test       # PHPCS + the whole suite, needs PostgreSQL

New behaviour wants a test that fails without it — the suite is checked by mutation, not by coverage percentage. Break the code, and something should go red.

CheckStandard
PHPCSPSR-12 over src and tests
PHPStanLevel 6 with larastan; src is analysed without a single exemption
PHPUnitFails on warnings, notices, deprecations, risky tests and output
MatrixPostgreSQL 13 – 18, plus a lowest-dependencies run

Design decisions

Four things in this package look like duplicated framework code and are kept deliberately. They were each checked against Laravel 13 and found justified; the reasons are here so the question does not get reopened.

KeptWhy it is not framework duplication
Builder::hasView()The framework's reads pg_views, which is relkind = 'v'. Materialized views live in pg_matviews (relkind = 'm') and the two sets are disjoint, so the native method cannot see them
Builder::getViewDefinition()Same reason: native compileViews() reads pg_views only
Blueprint::ginIndex()A shorthand over indexCommand(), not a copy. It also carries an operator class, which the framework accepts on spatial and vector indexes only
Connection::updateAndReturn() / deleteAndReturn()A live public API with callers in src/, in the tests, in .meta.php and in the documentation

Builder::createExtension() / createExtensionIfNotExists() and the two …AndReturn() methods are each two lines apart from their sibling. Collapsing them would change a public API for a very small gain, so they stay as they are.

The PHP floor is a deliberate choice, not a requirement. Laravel 13 itself needs only php: ^8.3, and nothing in src/ uses an 8.4 or 8.5 construct — ^8.4 || ^8.5 would reach twice as many applications at the same level of modernity. >= 8.5 was chosen anyway, and this is the record of that trade-off.

The rule that matters most: do not copy framework methods — call parent:: and add to it. Five methods had been copied wholesale at one point, and one of them had already drifted out of sync, silently dropping two service bindings. None remain. GrammarTable::compileDropIfExists() is the pattern to follow, and #[\Override] belongs on every override so PHP itself catches a parent method that disappears.