Migrations
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
migrationstable) - 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, andgetDescription. MissinggetDescription()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:
| Column | Purpose |
|---|---|
| migration | Filename of the migration |
| batch | Batch number for grouped rollback |
| applied_at | Timestamp applied |
| checksum | SHA256 hash of file contents |
| description | Your getDescription() output |
| extension | Source extension (null for core) |
| source | Owning package name (app for the skeleton; the package name for framework/extension migrations) |
Supported Column Types
Status: Implemented unless marked Pending.
| Method | Status | Notes |
|---|---|---|
| id() | Implemented | Auto-increment primary (defaults to id) |
| string(name, length=255) | Implemented | Variable length text |
| text(name) | Implemented | Large text |
| integer(name) | Implemented | Standard integer |
| bigInteger(name) | Implemented | 64-bit integer |
| boolean(name) | Implemented | Boolean flag |
| decimal(name, precision=8, scale=2) | Implemented | Fixed precision |
| float(name, precision=8, scale=2) | Implemented | Floating point |
| double(name, precision=15, scale=8) | Implemented | Double precision |
| timestamp(name) | Implemented | Timestamp column |
| dateTime(name) | Implemented | Distinct from timestamp |
| date(name) | Implemented | Date only |
| time(name) | Implemented | Time only |
| json(name) | Implemented | JSON storage |
| uuid(name) | Implemented | UUID text storage; skeleton often uses string('uuid', 12) |
| enum(name, values, default?) | Implemented | Enumerated constraint |
| binary(name, length?) | Implemented | Binary blob / fixed length |
| foreignId(name) | Implemented | Big integer + FK convenience; pairs with constrained() |
| longText(name) | Pending | Use text() for now |
| smallInteger(name) | Pending | Use integer() |
| tinyInteger(name) | Pending | Use integer() |
| jsonb(name) | Pending | Use 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:
| Modifier | Purpose |
|---|---|
| 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:
- Column then explicit foreign() builder
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 useforeign():$table->string('user_uuid', 12); $table->foreign('user_uuid')->references('uuid')->on('users')->restrictOnDelete(); - Use
foreignId()->constrained()primarily for numericid-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
- Generate:
php glueful migrate:create create_tasks_table - Implement
getDescription,up,down - Add schema using fluent builder
- Run:
php glueful migrate:run - Verify:
php glueful migrate:status - 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.
| Owner | source | Examples |
|---|---|---|
| Framework core | glueful/framework / glueful/framework:<capability> | auth_sessions, api_keys, queue_jobs, notifications |
| Extensions | the package name | users/profiles (glueful/users), roles/permissions (glueful/aegis) |
| Your app | app | everything in database/migrations/ |
Ordering by priority
Pending migrations run in a deterministic order — (priority, basename, source). Priorities come from Glueful\Database\Migrations\MigrationPriority:
| Tier | Value | Used by |
|---|---|---|
FOUNDATION | -200 | framework core (auth + capabilities) |
IDENTITY | -100 | the user store (glueful/users) |
DEFAULT | 0 | your app / skeleton |
DEPENDENT | 100 | things 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:
| Capability | Tables | Gate |
|---|---|---|
| auth | auth_sessions, auth_refresh_tokens, api_keys | always on |
| uploads | blobs | UPLOADS_ENABLED |
| queue | queue_jobs, queue_failed_jobs, queue_batches | QUEUE_CONNECTION=database |
| scheduler | scheduled_jobs, job_executions | capabilities.scheduler |
| notifications | notifications, notification_deliveries, … | capabilities.notifications |
| metrics | api_metrics, api_metrics_daily, api_rate_limits | capabilities.metrics |
| locks | locks | LOCK_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).
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::DEPENDENTwhen your tables reference the user store, so they run afterglueful/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 indexeduser_uuidUUID column with noforeign()constraint, and resolve users throughUserProviderInterfacein code. - Provide a clear
getDescription()that names the extension.
Next Steps
- Database — Query your migrated tables
- Controllers — Expose resources via API
- Validation — Enforce data integrity at input layer
Accurate as of current TableBuilderInterface and schema subsystem. If you add new column types or helpers, update this document alongside the implementation.