Essentials

Migrations

Versioned, testable database schema changes

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

Key capabilities:

  • Integrity tracking (checksum + description stored in migrations table)
  • Batch-based rollback (step or full)
  • Extension discovery (enabled extensions can contribute migrations)
  • Fluent schema builder with preview/validate hooks
  • Safe reversible contract (up(), down(), getDescription())

Always implement all three interface methods: up, down, and getDescription. Missing getDescription() will prevent proper auditing and status introspection.

Creating a Migration

Generate a new class (use snake_case name):

php glueful migrate:create create_users_table

This creates a numbered file in database/migrations/ (e.g. 001_create_users_table.php). Implement all interface methods:

<?php

namespace Glueful\Database\Migrations;

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

class CreateUsersTable implements MigrationInterface
{
    public function getDescription(): string
    {
        return 'Create users table with auth + lifecycle fields';
    }

    public function up(SchemaBuilderInterface $schema): void
    {
        // Using callback auto-executes the create immediately
        $schema->createTable('users', function($table) {
            $table->id();                                 // Auto-increment primary key (id)
            $table->string('uuid', 12)->unique();         // External identifier
            $table->string('name', 100);
            $table->string('email')->unique();
            $table->string('password');
            $table->boolean('is_active')->default(true);
            $table->timestamps();                        // created_at / updated_at
            $table->softDeletes();                       // deleted_at
        });
    }

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

Why getDescription() Matters

The MigrationManager persists your description along with checksum and extension name, supporting audit trails, CLI status clarity, and potential UI tooling.

Executing & Reverting

# Run all pending migrations
php glueful migrate:run

# Rollback last batch (default 1 step)
php glueful migrate:rollback

# Rollback N individual migrations (most recent first)
php glueful migrate:rollback --steps=3

# Reset (drop all tables)
php glueful db:reset

# Fresh start: drop then re-run migrations
php glueful db:reset && php glueful migrate:run

# Show applied vs pending
php glueful migrate:status

Behind the scenes each applied migration is recorded with:

ColumnPurpose
migrationFilename of the migration
batchBatch number for grouped rollback
applied_atTimestamp applied
checksumSHA256 hash of file contents
descriptionYour getDescription() output
extensionSource extension (null for core)
sourceOwning package name (app for the skeleton; the package name for framework/extension migrations)

Supported Column Types

Status: Implemented unless marked Pending.

MethodStatusNotes
id()ImplementedAuto-increment primary (defaults to id)
string(name, length=255)ImplementedVariable length text
text(name)ImplementedLarge text
integer(name)ImplementedStandard integer
bigInteger(name)Implemented64-bit integer
boolean(name)ImplementedBoolean flag
decimal(name, precision=8, scale=2)ImplementedFixed precision
float(name, precision=8, scale=2)ImplementedFloating point
double(name, precision=15, scale=8)ImplementedDouble precision
timestamp(name)ImplementedTimestamp column
dateTime(name)ImplementedDistinct from timestamp
date(name)ImplementedDate only
time(name)ImplementedTime only
json(name)ImplementedJSON storage
uuid(name)ImplementedUUID text storage; skeleton often uses string('uuid', 12)
enum(name, values, default?)ImplementedEnumerated constraint
binary(name, length?)ImplementedBinary blob / fixed length
foreignId(name)ImplementedBig integer + FK convenience; pairs with constrained()
longText(name)PendingUse text() for now
smallInteger(name)PendingUse integer()
tinyInteger(name)PendingUse integer()
jsonb(name)PendingUse json()

Column Modifiers & Fluent Constraints

All column methods return a ColumnBuilderInterface enabling chained constraints:

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

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

$table->timestamp('created_at')
    ->useCurrent();           // CURRENT_TIMESTAMP

$table->timestamp('updated_at')
    ->nullable()
    ->useCurrent()
    ->useCurrentOnUpdate();

Selected modifier capabilities:

ModifierPurpose
nullable() / notNull()Nullability control
default(value)Static default
defaultRaw(expr)Raw SQL default (e.g. CURRENT_TIMESTAMP)
useCurrent()CURRENT_TIMESTAMP default
useCurrentOnUpdate()Auto-update timestamp on update
unique(name?)Unique index
index(name?)Plain index
primary()Primary key designation
autoIncrement()Auto increment numeric
unsigned()Unsigned numeric (where supported)
check(expr)CHECK constraint
comment(text)Column comment
charset()/collation()MySQL column-level overrides
after()/first()MySQL positional
constrained(table?, column='id')Foreign key helper on foreignId() or manually named column
cascadeOnDelete()/nullOnDelete()/restrictOnDelete()/noActionOnDelete()FK delete behaviors
cascadeOnUpdate()/restrictOnUpdate()/noActionOnUpdate()FK update behaviors

Indexes & Keys

// Single
$table->index('email');

// Composite
$table->index(['user_id', 'created_at']);

// Unique
$table->unique('username');

// Named
$table->index('status', 'idx_users_status');

// Primary (composite)
$table->primary(['tenant_id', 'code']);

// Fulltext (where supported)
$table->fulltext('content');

Foreign Keys

Two primary patterns:

  1. Column then explicit foreign() builder
  2. foreignId()->constrained() shortcut
$table->foreignId('user_id')
    ->constrained('users')   // References users.id
    ->cascadeOnDelete();

$table->string('created_by', 12)->nullable();
$table->foreign('created_by')
    ->references('uuid')
    ->on('users')
    ->nullOnDelete();

Available actions: cascadeOnDelete, cascadeOnUpdate, nullOnDelete, restrictOnDelete, restrictOnUpdate, noActionOnDelete, noActionOnUpdate.

Note on UUID foreign keys:

  • If your parent key is a UUID column (e.g., users.uuid), define a matching string column and use foreign():
    $table->string('user_uuid', 12);
    $table->foreign('user_uuid')->references('uuid')->on('users')->restrictOnDelete();
    
  • Use foreignId()->constrained() primarily for numeric id-style foreign keys.

Complete Table Example

$schema->table('products')
    ->id()
    ->string('uuid', 12)->unique()
    ->string('name')
    ->string('slug')->unique()
    ->text('description')->nullable()
    ->decimal('price', 10, 2)->unsigned()->default(0)
    ->integer('stock')->default(0)
    ->boolean('is_active')->default(true)
    ->foreignId('category_id')->constrained('categories')->nullOnDelete()
    ->json('metadata')->nullable()
    ->timestamps()
    ->softDeletes()
    ->index('is_active')
    ->index(['category_id', 'is_active'])
    ->create()
    ->execute(); // Execute queued SQL

Altering Tables

Use alterTable() for structural changes; it returns the same fluent builder:

// Add columns
$schema->alterTable('users')
    ->string('phone')->nullable()
    ->date('birth_date')->nullable()
    ->execute();

// Rename & modify
$schema->alterTable('users')
    ->renameColumn('name', 'full_name')
    ->modifyColumn('email')->string(320)   // Change length
    ->execute();

// Drop column
$schema->alterTable('users')
    ->dropColumn('birth_date')
    ->execute();

// Add index & FK
$schema->alterTable('orders')
    ->foreignId('user_id')->constrained('users')->cascadeOnDelete()
    ->index('user_id')
    ->execute();

The earlier callback form $schema->table('users', function($table){ ... }); is replaced here by explicit fluent chains for clarity and preview/validate support.

Dropping Tables

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

Common Patterns

External UUID + Internal ID

$schema->table('posts')
    ->id()
    ->string('uuid', 12)->unique()
    ->string('title')
    ->text('content')
    ->foreignId('user_id')->constrained('users')->cascadeOnDelete()
    ->timestamps()
    ->create();

Soft Deletes & Timestamps

$table->timestamps();   // created_at, updated_at
$table->softDeletes();  // deleted_at

Auditing (User Attribution)

$table->foreignId('created_by')->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->constrained('users')->nullOnDelete();

Typical Workflow

  1. Generate: php glueful migrate:create create_tasks_table
  2. Implement getDescription, up, down
  3. Add schema using fluent builder
  4. Run: php glueful migrate:run
  5. Verify: php glueful migrate:status
  6. Rollback if needed: php glueful migrate:rollback

Preview & Validation

Before executing large or risky structural changes you can preview or validate pending operations (when using builder accumulation patterns):

// Stage operations without executing yet
$schema->table('bulk_demo')
    ->id()
    ->string('code', 32)->unique()
    ->timestamps()
    ->create();

// Preview and validate staged SQL on the SchemaBuilder
$sql = $schema->preview();      // Array of SQL statements
$result = $schema->validate();  // Validation metadata

// Execute when ready
$schema->execute();

Use preview() early when designing complex multi-step migrations to ensure generated SQL matches expectations across different drivers.

Best Practices

1. Always Implement down()

Reversibility improves confidence & enables continuous delivery rollbacks.

2. One Logical Concern per Migration

Avoid bundling unrelated table creations or modifications.

3. Describe Real Intent

getDescription() should explain business context, not just restate the class name.

4. Index Strategically

Add indexes for high-cardinality lookups & foreign keys that drive joins.

5. Prefer foreignId()->constrained() for conventional FKs

It standardizes naming and reduces mistakes.

6. Validate / Preview Complex Sets

Especially when altering multiple tables in one batch.

7. Keep Migrations Immutable

If you must change a committed migration, create a follow-up corrective migration—checksum tracking will detect edits.

Troubleshooting

Problem: Migration fails with "table already exists". Cause: Table created manually or migration re-run after manual changes. Fix: Use db:reset or drop table manually then migrate:run.

Problem: Rollback fails. Cause: down() incomplete or dependency order issue. Fix: Ensure reverse operations mirror up() and drop dependent constraints first.

Problem: Foreign key constraint error. Cause: Order mismatch or missing index. Fix: Create parent table first; ensure column types match; add index if performance-critical.

Problem: Edited old migration now shows checksum mismatch (future feature visibility). Fix: Do not edit historical migrations; append a new corrective migration.

Migration Ownership, Ordering & Capabilities

Glueful runs one ordered migration stream assembled from several owners — framework core, enabled extensions, and your app — each 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 in the migrations table with a source label plus its basename, so two owners can ship the same filename (e.g. 001_…) without conflict, and a rollback targets the right one.

OwnersourceExamples
Framework coreglueful/framework / glueful/framework:<capability>auth_sessions, api_keys, queue_jobs, notifications
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 (auth + capabilities)
IDENTITY-100the user store (glueful/users)
DEFAULT0your app / skeleton
DEPENDENT100things built on identity (e.g. glueful/aegis roles/permissions)

So a typical stack migrates core → users → app → aegis.

Core capability schema

The framework ships its own migrations for the subsystems whose code it owns, installed only when that capability is enabled — an off capability creates no tables, and migrate:status won't list them:

CapabilityTablesGate
authauth_sessions, auth_refresh_tokens, api_keysalways on
uploadsblobsUPLOADS_ENABLED
queuequeue_jobs, queue_failed_jobs, queue_batchesQUEUE_CONNECTION=database
schedulerscheduled_jobs, job_executionscapabilities.scheduler
notificationsnotifications, notification_deliveries, …capabilities.notifications
metricsapi_metrics, api_metrics_daily, api_rate_limitscapabilities.metrics
lockslocksLOCK_DRIVER=database

Capabilities with a natural driver signal follow it (QUEUE_CONNECTION, LOCK_DRIVER, UPLOADS_ENABLED); the rest are toggled in config/capabilities.php (via env, e.g. SCHEDULE_DATABASE_STORE, NOTIFICATIONS_DATABASE_STORE, METRICS_DATABASE_STORE).

Archiving moved to glueful/archive (1.52.0)
Queue worker / job-metrics tables ship with glueful/queue-ops

No runtime table creation. Core subsystems no longer create tables lazily on a request — a missing table is a normal "no such table" error telling you to migrate, not silent production DDL. If a capability table is missing, confirm its gate is on, then run php glueful migrate:run.

Extension Migrations

Extensions register their own migration directory with a priority and source via loadMigrationsFrom($dir, $priority, $source) (see Ordering by priority above and the extensions guide). Migrations are tracked by their source so they're audited and rolled back independently.

Guidelines:

  • Register at MigrationPriority::DEPENDENT when your tables reference the user store, so they run after glueful/users.
  • Pass a source (your package name) — filename collisions with core/app migrations are then fine.
  • Don't add a database foreign key into users. The user store is a swappable extension; store the principal as an indexed user_uuid UUID column with no foreign() constraint, and resolve users through UserProviderInterface in code.
  • Provide a clear getDescription() that names the extension.

Next Steps


Accurate as of current TableBuilderInterface and schema subsystem. If you add new column types or helpers, update this document alongside the implementation.