Essentials

Migrations

Versioned, testable database schema changes

Glueful migrations give you version control over your database schema. Each migration is an isolated, reversible change. It is tracked with a checksum, batch number, description and owning source.

Key capabilities:

  • Integrity tracking (checksum + description stored in the migrations table)
  • Step-based rollback, most recent first
  • One ordered stream across framework core, extensions and your app
  • Fluent schema builder with preview/validate hooks
  • Per-migration transactions on PostgreSQL and SQLite (DDL and its ledger receipt commit together)

Implement all three interface methods: up, down and getDescription.

Creating a Migration

Generate a new class (use a snake_case name):

php glueful migrate:create create_tasks_table

This creates a numbered file in database/migrations/ named after its class (e.g. 004_CreateTasksTable.php declaring CreateTasksTable, which is how the runner finds the class; since 1.91.1, earlier releases wrote a snake_case name the runner could not load). The callback form of createTable() / alterTable() is the canonical style. It builds the table, then executes immediately:

<?php

use Glueful\Database\Migrations\MigrationInterface;
use Glueful\Database\Schema\Interfaces\SchemaBuilderInterface;

class CreateTasksTable implements MigrationInterface
{
    public function up(SchemaBuilderInterface $schema): void
    {
        if ($schema->hasTable('tasks')) {
            return;
        }

        $schema->createTable('tasks', function ($table) {
            $table->id();                                 // Auto-increment primary key (id)
            $table->string('uuid', 12)->unique();         // External identifier
            $table->string('title', 200);
            $table->text('notes')->nullable();
            $table->boolean('is_done')->default(false);
            $table->string('user_uuid', 12);
            $table->timestamps();                         // created_at / updated_at
            $table->softDeletes();                        // deleted_at

            $table->index('user_uuid');
            $table->index(['is_done', 'created_at']);
        });
    }

    public function down(SchemaBuilderInterface $schema): void
    {
        $schema->dropTableIfExists('tasks');
    }

    public function getDescription(): string
    {
        return 'Create tasks table for the to-do feature';
    }
}

The hasTable() guard makes up() idempotent. That matters when a table may already exist, for example after a manual hotfix or when adopting an existing database. hasColumn($table, $column) does the same for columns.

Why getDescription() Matters

The description is persisted with the checksum and source. It shows up in migrate:status and gives you an audit trail of why each schema change exists.

Executing & Reverting

# Run all pending migrations
php glueful migrate:run
php glueful migrate:run --dry-run          # show what would run (alias: --pretend)
php glueful migrate:run --force            # skip the confirmation (deploys, CI)

# Roll back the most recent migration
php glueful migrate:rollback

# Roll back the 3 most recent migrations (most recent first)
php glueful migrate:rollback --steps=3

# Show applied vs pending
php glueful migrate:status

# Classify schema state per source (ready / adoptable / divergent)
php glueful migrate:verify

# Drop all tables (development only), then rebuild
php glueful db:reset && php glueful migrate:run

migrate:run lists the pending migrations and asks before running them. The answer defaults to no, so a non-interactive run (-n, or no TTY) prints "Migration cancelled." and exits 0 without migrating; pass --force in scripts.

Each applied migration is recorded with:

ColumnPurpose
migrationFilename of the migration
batchBatch number of the run that applied it
applied_atTimestamp applied
checksumSHA-256 hash of file contents
descriptionYour getDescription() output
extensionSource extension (null for core/app)
sourceOwning package (app for your migrations, the package name for framework/extension migrations)

migrate:verify and Adoption

migrate:verify reports every migration source as ready (all receipts present and checksums match), adoptable (tables already exist and the package's structural verifier confirms them), or divergent. For an existing database whose tables predate their ledger receipts, php glueful migrate:verify --adopt glueful/users writes the missing receipts once the verifier passes. It never drops anything. Add --json for machine-readable output.

Supported Column Types

MethodNotes
id(name='id')Auto-increment primary key
string(name, length=255)Variable length text
text(name)Large text
integer(name)Standard integer
bigInteger(name)64-bit integer
boolean(name)Boolean flag
decimal(name, precision=8, scale=2)Fixed precision
float(name, precision=8, scale=2)Floating point
double(name, precision=15, scale=8)Double precision
timestamp(name)Timestamp column
dateTime(name)Distinct from timestamp
date(name)Date only
time(name)Time only
json(name)JSON storage
uuid(name)UUID storage; the skeleton often uses string('uuid', 12)
enum(name, values, default?)Enumerated constraint
binary(name, length?)Binary blob / fixed length
foreignId(name)Big integer + FK convenience; pairs with constrained()
addColumn(name, type, options)Any other type by name

Table helpers: timestamps() (created_at default current, updated_at nullable), softDeletes() (nullable deleted_at, which the query builder honours automatically), rememberToken().

There is no longText(), smallInteger(), tinyInteger() or jsonb() helper. Use text(), integer() and json(), or addColumn().

Column Modifiers

Column methods return a ColumnBuilder for chaining constraints:

$table->string('email')
    ->unique()                // Unique index
    ->nullable(false)         // Explicit NOT NULL
    ->default('')             // Static default
    ->comment('Login email');

$table->decimal('price', 10, 2)
    ->unsigned()
    ->default(0)
    ->check('price >= 0');

$table->timestamp('published_at')
    ->nullable()
    ->useCurrent();
ModifierPurpose
nullable() / notNull() / notNullable()Nullability control
default(value)Static default
defaultRaw(expr)Raw SQL default
useCurrent()CURRENT_TIMESTAMP default
useCurrentOnUpdate()ON UPDATE CURRENT_TIMESTAMP (MySQL/MariaDB only, see below)
unique(name?) / index(name?)Single-column index
primary() / autoIncrement()Key designation
unsigned()Unsigned numeric (where supported)
check(expr)CHECK constraint
comment(text)Column comment
charset() / collation()MySQL column-level overrides
after(column) / first()MySQL positional
constrained(table?, column='id')Foreign key on a foreignId()
cascadeOnDelete() / nullOnDelete() / restrictOnDelete() / noActionOnDelete()FK delete behaviours
cascadeOnUpdate() / restrictOnUpdate() / noActionOnUpdate()FK update behaviours
end()Finish the column and return to the table builder

useCurrentOnUpdate() is MySQL-only. PostgreSQL and SQLite have no column-level ON UPDATE clause, so their generators throw UnsupportedSchemaOperationException rather than drop it. On those drivers, set updated_at in application code (the ORM does this on save) or maintain it with a trigger. Since 1.91: it used to be silently ignored on every driver; a portable migration that calls it now fails on PostgreSQL/SQLite, so remove the call there.

Indexes & Keys

$table->index('email');                         // Single
$table->index(['user_id', 'created_at']);       // Composite
$table->unique('username');                     // Unique
$table->index('status', 'idx_users_status');    // Named
$table->primary(['tenant_id', 'code']);         // Composite primary
$table->fulltext('content');                    // Fulltext (where supported)

Foreign Keys

// Numeric id-style FK
$table->foreignId('category_id')
    ->constrained('categories')   // References categories.id
    ->nullOnDelete();

// String/UUID FK via the foreign() builder
$table->string('created_by', 12)->nullable();
$table->foreign('created_by')
    ->references('uuid')
    ->on('posts')
    ->nullOnDelete();

The foreign() builder supports cascadeOnDelete, nullOnDelete, restrictOnDelete, noActionOnDelete, the matching …OnUpdate methods, name() and deferrable().

Don't add a database foreign key into users. The user store (glueful/users) is a swappable extension. Store the principal as an indexed user_uuid column with no foreign() constraint, and resolve users through UserProviderInterface in code.

Altering Tables

Use the callback form of alterTable(). It applies and executes the changes immediately:

// Add columns
$schema->alterTable('tasks', function ($table) {
    $table->string('priority', 16)->default('normal');
    $table->date('due_on')->nullable();
    $table->index('due_on', 'idx_tasks_due_on');
});

// Rename, modify, drop
$schema->alterTable('tasks', function ($table) {
    $table->renameColumn('notes', 'description');
    $table->modifyColumn('title')->string(300);
    $table->dropIndex('idx_tasks_due_on');
    $table->dropColumn('due_on');
});

Other table-level operations: dropUnique(name), dropPrimary(), dropForeign(name), rename(newName).

SQLite: SQLite can't natively modify or drop columns, change foreign keys, or drop an inline unique constraint. Glueful performs these through an audited create-copy-swap table rebuild, one per alterTable() call. If a rebuild can't preserve something (generated columns, triggers or views on changed columns, and similar), it throws UnsupportedSchemaOperationException before changing anything.

Fluent (Non-Callback) Form

Without a callback, table() / createTable() / alterTable() return the table builder so you can stage operations. Column methods return a ColumnBuilder, so call ->end() to get back to the table before the next column. Chaining ->string('x') directly onto a column builder is a TypeError. Finish with create() (for new tables) and execute():

$schema->table('products')
    ->id()->end()
    ->string('uuid', 12)->unique()->end()
    ->string('name')->end()
    ->decimal('price', 10, 2)->unsigned()->default(0)->end()
    ->foreignId('category_id')->constrained('categories')->nullOnDelete()->end()
    ->timestamps()
    ->softDeletes()
    ->index(['category_id', 'name'])
    ->create()
    ->execute();

Preview & Validation

The fluent form lets you inspect SQL before running it:

$schema->table('bulk_demo')
    ->id()->end()
    ->string('code', 32)->unique()->end()
    ->create();

$sql    = $schema->preview();   // Array of pending SQL statements
$result = $schema->validate();  // Validation metadata

$schema->execute();             // Run when ready (or $schema->reset() to discard)

Dropping Tables

$schema->dropTable('tasks');            // Fails if missing
$schema->dropTableIfExists('archive');

Typical Workflow

  1. Generate: php glueful migrate:create create_tasks_table
  2. Implement up (guarded with hasTable()), down and getDescription
  3. Run: php glueful migrate:run
  4. Verify: php glueful migrate:status
  5. Roll back if needed: php glueful migrate:rollback

Best Practices

  1. Always implement down(). Reversibility gives you confidence and makes rollbacks possible.
  2. One logical concern per migration. Avoid bundling unrelated tables.
  3. Describe real intent. getDescription() should explain the business context.
  4. Guard with hasTable() / hasColumn() so re-runs and adoptions are safe.
  5. Index strategically: lookup columns and the columns that drive joins.
  6. Keep migrations immutable. Checksums detect edits, so ship a follow-up migration instead of changing an applied one.

Troubleshooting

"Table already exists": The table was created manually or outside the ledger. Add a hasTable() guard, or for an extension's tables use migrate:verify --adopt <source>.

Rollback fails: down() is incomplete or drops in the wrong order. Mirror up() and drop dependent tables and constraints first.

Foreign key error: Create the parent table first (check priorities) and make sure the column types match exactly.

UnsupportedSchemaOperationException on SQLite: the rebuild audit refused a change it can't preserve. Restructure the migration (e.g. drop the trigger or view first, then recreate it).

Migration Ownership, Ordering & Core Tables

Glueful runs one ordered migration stream assembled from several owners: framework core, extensions and your app. Each owner is tracked separately, so they never collide.

Ownership & the source column

A table's migration belongs to whichever package reads and writes it. Each applied migration is recorded with a source label plus its basename. Two owners can therefore ship the same filename (e.g. 001_…) without conflict, and a rollback targets the right one.

OwnersourceExamples
Framework coreglueful/framework / glueful/framework:<leaf>auth_sessions, api_keys, queue_jobs, extension_operations
Extensionsthe package nameusers/profiles (glueful/users), roles/permissions (glueful/aegis)
Your appappeverything in database/migrations/

Ordering by priority

Pending migrations run in a deterministic order: (priority, basename, source). Priorities come from Glueful\Database\Migrations\MigrationPriority:

TierValueUsed by
FOUNDATION-200framework core
IDENTITY-100the user store (glueful/users)
PLATFORM-50control-plane schema after identity, before the app (e.g. glueful/tenancy)
DEFAULT0your app / skeleton
DEPENDENT100things built on identity (e.g. glueful/aegis roles/permissions)

So a typical stack migrates core → users → (platform) → app → aegis.

Core tables

The framework ships migrations for the subsystems whose code it owns. Since 1.79 they are always installed. Configuration decides whether a subsystem is used at runtime, not whether its tables exist:

LeafTables
authauth_sessions, auth_refresh_tokens, api_keys
extensionsextension_operations (the enable/disable operation ledger)
uploadsblobs
queuequeue_jobs, queue_failed_jobs, queue_batches
schedulerscheduled_jobs, job_executions
notificationsnotifications, notification_deliveries, notification_preferences, notification_templates, notification_retry_queue
metricsapi_metrics, api_metrics_daily, api_rate_limits
lockslocks
webhookswebhook_subscriptions, webhook_deliveries (since 1.91; created lazily before)

Installing a table doesn't mean the subsystem uses it: the queue uses queue_jobs only with QUEUE_CONNECTION=database, locks use locks only with LOCK_DRIVER=database, and so on. config/capabilities.php has one remaining switch, notifications (NOTIFICATIONS_DATABASE_STORE), which decides whether notifications persist at runtime. It doesn't affect which tables are created. The former scheduler and metrics switches (SCHEDULE_DATABASE_STORE, METRICS_DATABASE_STORE) were removed in 1.91 because nothing read them; drop them from published config copies.

No runtime table creation. Core subsystems never create tables lazily on a request. A missing table is a normal "no such table" error telling you to run php glueful migrate:run.

Archiving (glueful/archive) and queue-worker/job-metrics tables (glueful/queue-ops) ship with their extensions, not with core.

Extension Migrations

Every installed Glueful package must declare its schema in composer.json under extra.glueful.migrations, as a list of descriptors or the string "none":

{
  "extra": {
    "glueful": {
      "migrations": [
        { "id": "default", "path": "migrations", "priority": "dependent", "mode": "on_enable" }
      ]
    }
  }
}

priority is one of foundation | identity | platform | default | dependent. With mode: on_enable, the package's migrations run when the extension is enabled (php glueful extensions:enable … migrates first, then flips the enabled state). A global migrate:run skips disabled extensions. The declaration registers the lane: the provider does not call loadMigrationsFrom() for a declared path (a call that contradicts the manifest throws DescriptorValidationException). A package with no schema declares "migrations": "none"; enabling a package that declares nothing throws UndeclaredSchemaException. App-local providers that belong to no package can still use loadMigrationsFrom().

php glueful create:extension scaffolds this declaration (a default on_enable lane at migrations/). Extensions scaffolded before 1.91 need it added by hand before extensions:enable will run their migrations.

Guidelines:

  • Use dependent when your tables reference the user store, so they run after glueful/users.
  • Keep migrations in a flat migrations/ directory at the package root.
  • Give each migration a clear getDescription() that names the extension.

See the extensions cookbook for the full provider setup.

Next Steps