Migrations
How database schema is created and versioned with migrations
Building the Database Structure
Migrations are like version control for your database schema:
- Create tables
- Add columns
- Modify structure
- Track what's been run
Think of it like building instructions:
- Step 1: Create users table
- Step 2: Create posts table
- Step 3: Add email_verified column to users
Each step is a migration file.
What is a Migration?
A migration is a SQL file that creates or modifies database structure:
-- database/migrations/001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);Key points:
- Plain SQL (no abstraction)
- Numbered for order (001, 002, 003)
- Descriptive name
- Idempotent (safe to run multiple times)
The Migration Class (Real Behavior)
DALT’s migration system is intentionally “raw SQL first”, but it supports both SQLite and PostgreSQL.
Two important behaviors to know:
- The migration system creates its own
migrationstracking table (SQLite or PostgreSQL syntax depending on the driver). - When
DB_DRIVER=pgsqlbut a migration file contains obvious SQLite-only syntax (likeAUTOINCREMENT), DALT tries a small automatic conversion for the common cases.
Driver detection
The migration runner checks which PDO driver is active:
private function driver(): string
{
return (string) $this->database->getConnection()->getAttribute(PDO::ATTR_DRIVER_NAME);
}The migrations tracking table
The tracking table is created with driver-specific SQL:
public function createMigrationsTable()
{
$driver = $this->driver();
if ($driver === 'sqlite') {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration VARCHAR(255) NOT NULL,
batch INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)";
} elseif ($driver === 'pgsql') {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id BIGSERIAL PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
batch INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)";
} else {
throw new \RuntimeException(\"Unsupported database driver: {$driver}\");
}
$this->database->getConnection()->exec($sql);
}What this stores:
| id | migration | batch | created_at |
|---|---|---|---|
| 1 | 001_create_users_table.sql | 1 | 2024-01-15 10:30:00 |
| 2 | 002_create_posts_table.sql | 1 | 2024-01-15 10:30:01 |
| 3 | 003_add_email_verified.sql | 2 | 2024-01-16 14:20:00 |
Why track migrations?
- Know what's been run
- Don't run the same migration twice
- Support rollback (batch numbers)
- Audit trail
The Migration Flow
Step 1: Find Migration Files
$migrationsPath = base_path('database/migrations');
$files = glob($migrationsPath . '/*.sql');
sort($files);What glob() does:
- Finds all
.sqlfiles in the directory - Returns array of full paths
Why sort()?
- Ensures migrations run in order
001_...before002_...before003_...
Step 2: Get Next Batch Number
public function getNextBatch()
{
$result = $this->database->query(
'SELECT MAX(batch) as max_batch FROM migrations'
)->find();
return ($result['max_batch'] ?? 0) + 1;
}What is a batch?
- A group of migrations run together
- Useful for rollback (undo a batch)
Example:
First run: batch = 1
Second run: batch = 2
Third run: batch = 3Step 3: Check if Already Run
public function hasRun($migration)
{
$result = $this->database->query(
'SELECT migration FROM migrations WHERE migration = ?',
[$migration]
)->find();
return $result !== false;
}Returns:
trueif migration exists in tracking tablefalseif not run yet
Step 4: Run the Migration
$sql = file_get_contents($file);
try {
$this->database->getConnection()->exec($sql);
$this->markAsRun($migration, $batch);
$ranMigrations++;
echo "✓ Success\n";
} catch (\PDOException $e) {
throw new \RuntimeException(
"Migration failed.\n" .
"File: {$migration}\n" .
"Error: " . $e->getMessage() . "\n",
previous: $e
);
}What exec() does:
- Executes SQL directly
- No prepared statements needed (no user input)
- Returns number of affected rows
Why stop on error?
- Stop running migrations if one fails
- Don't leave database in inconsistent state
- Fix the error before continuing
Step 5: Mark as Run
public function markAsRun($migration, $batch)
{
$this->database->query(
'INSERT INTO migrations (migration, batch) VALUES (?, ?)',
[$migration, $batch]
);
}Adds a row to the tracking table.
Creating a Migration
Manual Creation
touch database/migrations/002_create_posts_table.sqlNaming convention:
- Number prefix (001, 002, 003)
- Descriptive name
.sqlextension
Migration Content
-- database/migrations/002_create_posts_table.sql
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
published BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published);Best practices:
- Use
IF NOT EXISTS(idempotent) - Add indexes for foreign keys
- Add indexes for frequently queried columns
- Include timestamps
Running Migrations
Via CLI
php artisan migrateOutput:
Running migration: 001_create_users_table.sql
✓ Success
Running migration: 002_create_posts_table.sql
✓ Success
Ran 2 migrations.Automatic on First Run
Remember from Part 3.2, DatabaseManager checks if tables exist:
private function ensureTablesExist()
{
try {
$this->database->query("SELECT 1 FROM users LIMIT 1");
} catch (\Exception $e) {
$this->runMigrations();
}
}First time you run the app:
- Try to query
userstable - Fails (table doesn't exist)
- Automatically runs migrations
- Creates all tables
Subsequent runs:
- Query succeeds
- Skip migrations
SQLite vs PostgreSQL Differences
SQLite Syntax
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE
);PostgreSQL Syntax
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);Key differences:
INTEGERvsSERIALAUTOINCREMENTvs auto-increment by defaultTEXTvsVARCHAR(255)
DALT's Solution
The Migration class has conversion logic:
private function convertSqliteToPostgres($sql)
{
$sql = str_replace('INTEGER PRIMARY KEY AUTOINCREMENT', 'SERIAL PRIMARY KEY', $sql);
$sql = str_replace('TEXT', 'VARCHAR(255)', $sql);
$sql = str_replace('DATETIME', 'TIMESTAMP', $sql);
$sql = str_replace('BOOLEAN', 'BOOLEAN', $sql);
return $sql;
}When it's used:
if ($driver === 'pgsql') {
$sql = $this->convertSqliteToPostgres($sql);
}Limitations:
- Simple string replacement
- Won't handle complex SQL
- Better to write driver-specific migrations for production
Migration Patterns
Creating a Table
CREATE TABLE IF NOT EXISTS table_name (
id INTEGER PRIMARY KEY AUTOINCREMENT,
column1 TEXT NOT NULL,
column2 INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);Adding a Column
-- SQLite doesn't support ALTER TABLE ADD COLUMN with constraints
-- So we need to be careful
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0;Creating an Index
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);Adding Foreign Key
-- In the CREATE TABLE statement
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADEWhat ON DELETE CASCADE means:
- If a user is deleted
- All their posts are automatically deleted
- Maintains referential integrity
Common Issues
Issue 1: Migration Already Run
Problem:
Running migration: 001_create_users_table.sql
✗ Failed: table users already existsSolution:
- Migration was already run
- Check
migrationstable - Don't run it again
Issue 2: Syntax Error
Problem:
Running migration: 002_create_posts_table.sql
✗ Failed: near "AUTOINCREMENT": syntax errorSolution:
- Check SQL syntax
- Verify it works in your database
- Test in SQLite/PostgreSQL CLI first
Issue 3: Dependency Order
Problem:
Running migration: 002_create_posts_table.sql
✗ Failed: foreign key constraint failedSolution:
postsreferencesusersusersmust be created first- Check migration numbering
Issue 4: Can't Rollback
Problem:
- Ran a bad migration
- Want to undo it
Solution:
- DALT doesn't have rollback (yet)
- Manually drop tables or write a "down" migration
- For production, you'd want:
public function rollback($steps = 1) { // Undo last $steps batches }
Best Practices
1. Always Use IF NOT EXISTS
CREATE TABLE IF NOT EXISTS users (...);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);Makes migrations idempotent (safe to run multiple times).
2. Number Migrations
001_create_users_table.sql
002_create_posts_table.sql
003_create_comments_table.sqlEnsures correct order.
3. One Logical Change Per Migration
Good:
001_create_users_table.sql
002_add_email_verified_to_users.sqlBad:
001_create_everything.sql (creates 10 tables)4. Test Before Committing
# Test in SQLite
sqlite3 test.db < database/migrations/001_create_users_table.sql
# Test in PostgreSQL
psql -d testdb -f database/migrations/001_create_users_table.sql5. Never Modify Existing Migrations
Once a migration is run in production, don't change it.
Instead:
- Create a new migration
- Example:
003_add_column_to_users.sql
Key Takeaways
- Migrations are SQL files - Plain SQL, no abstraction
- Tracking table prevents duplicates - Knows what's been run
- Batch numbers enable rollback - Group migrations together
- Order matters - Dependencies must run first
- Idempotent is safe - Use
IF NOT EXISTS
What's Good Here
✅ Simple implementation (just SQL files)
✅ No abstraction (learn real SQL)
✅ Automatic on first run (convenient)
✅ Batch tracking (supports rollback)
✅ Forces you to write real SQL migrations
Design Note
Rollback functionality and migration generators are intentionally omitted. DALT keeps migrations simple - just SQL files that run once. This teaches you database schema management without the complexity of up/down migrations. For production, use Laravel's migrations which handle all of this.
Section 3 Complete!
You now understand:
- How the dependency injection container works
- How database connections are created
- How queries are executed securely
- How migrations manage schema changes
Next, explore sessions and flash data.
- "Down" SQL in each migration
- More sophisticated tracking
- Code generation
Part 3 Complete!
You now understand the container and database:
- How dependency injection works
- How the container manages services
- How database connections are created
- How queries are executed safely
- How migrations version your schema