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)

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.

Extension Migrations

Extensions can register additional migration paths; those migrations are tracked with the extension column. This enables selective auditing & future enable/disable workflows.

Guidelines:

  • Avoid naming collisions with core migrations
  • Provide clear getDescription() context that names the extension
  • Use foreign keys referencing extension tables only after their creation migration

Next Steps


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