Migrations
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
migrationstable) - 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,downandgetDescription.
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:
| Column | Purpose |
|---|---|
| migration | Filename of the migration |
| batch | Batch number of the run that applied it |
| applied_at | Timestamp applied |
| checksum | SHA-256 hash of file contents |
| description | Your getDescription() output |
| extension | Source extension (null for core/app) |
| source | Owning 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
| Method | Notes |
|---|---|
| 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();
| Modifier | Purpose |
|---|---|
| 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
- Generate:
php glueful migrate:create create_tasks_table - Implement
up(guarded withhasTable()),downandgetDescription - Run:
php glueful migrate:run - Verify:
php glueful migrate:status - Roll back if needed:
php glueful migrate:rollback
Best Practices
- Always implement
down(). Reversibility gives you confidence and makes rollbacks possible. - One logical concern per migration. Avoid bundling unrelated tables.
- Describe real intent.
getDescription()should explain the business context. - Guard with
hasTable()/hasColumn()so re-runs and adoptions are safe. - Index strategically: lookup columns and the columns that drive joins.
- 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.
| Owner | source | Examples |
|---|---|---|
| Framework core | glueful/framework / glueful/framework:<leaf> | auth_sessions, api_keys, queue_jobs, extension_operations |
| 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 |
IDENTITY | -100 | the user store (glueful/users) |
PLATFORM | -50 | control-plane schema after identity, before the app (e.g. glueful/tenancy) |
DEFAULT | 0 | your app / skeleton |
DEPENDENT | 100 | things 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:
| Leaf | Tables |
|---|---|
| auth | auth_sessions, auth_refresh_tokens, api_keys |
| extensions | extension_operations (the enable/disable operation ledger) |
| uploads | blobs |
| queue | queue_jobs, queue_failed_jobs, queue_batches |
| scheduler | scheduled_jobs, job_executions |
| notifications | notifications, notification_deliveries, notification_preferences, notification_templates, notification_retry_queue |
| metrics | api_metrics, api_metrics_daily, api_rate_limits |
| locks | locks |
| webhooks | webhook_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
dependentwhen your tables reference the user store, so they run afterglueful/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
- Database — Query your migrated tables
- Controllers — Expose resources via API
- Validation — Enforce data integrity at the input layer