Cycle/Database provides a powerful declarative approach to define and manage database table structures, foreign keys, and indexes. The schema declaration system compares your desired schema with the current database state and automatically generates the necessary SQL operations.
The schema declaration system operates through a comparison-based approach:
save() is calledThis declarative approach means you define what you want the schema to look like, not how to change it. DBAL handles the transformation automatically.
Note
The schema system can work alongside external migration systems. See the Migrations documentation for integration details.
To work with table schemas, obtain an AbstractTable instance from your database:
use Cycle\Database\Database;
$database = new Database(/* ... */);
// Get schema for new or existing table
$schema = $database->table('users')->getSchema();
// Check if table exists in database
if ($schema->exists()) {
echo "Table exists";
} else {
echo "Table will be created";
}
Note
You don't need to check for table existence before working with the schema. The system handles both creation and modification transparently.
Add columns to your schema using fluent method calls:
$schema = $database->table('users')->getSchema();
// Method 1: Explicit column() call
$schema->column('id')->primary();
$schema->column('email')->string(255);
$schema->column('balance')->decimal(10, 2);
// Method 2: Direct shortcut (recommended)
$schema->primary('id');
$schema->string('email', 255);
$schema->decimal('balance', 10, 2);
// Save changes to database
$schema->save();
Both approaches are equivalent, but shortcuts are more concise for simple declarations.
Cycle DBAL uses abstract types that map to appropriate database-specific column types. This ensures your schema works across different database systems.
Type Mapping Example:
// This declaration:
$schema->string('username', 64);
// Generates in MySQL:
// `username` VARCHAR(64) NULL
// Generates in PostgreSQL:
// "username" CHARACTER VARYING(64) NULL
// Generates in SQLite:
// "username" TEXT NULL
| Type | Method | Description | Example |
|---|---|---|---|
| primary | primary($column) |
Auto-incrementing integer primary key (32-bit) | $schema->primary('id') |
| bigPrimary | bigPrimary($column) |
Auto-incrementing big integer primary key (64-bit) | $schema->bigPrimary('id') |
| smallPrimary | smallPrimary($column) |
Auto-incrementing small integer primary key (16-bit) | $schema->smallPrimary('id') |
Usage Notes:
bigPrimary for tables expecting billions of records| Type | Method | Description | Typical Range | Example |
|---|---|---|---|---|
| integer | integer($column) |
Standard integer (32-bit) | -2,147,483,648 to 2,147,483,647 | $schema->integer('age') |
| tinyInteger | tinyInteger($column) |
Small integer (8-bit) | -128 to 127 (or 0 to 255 unsigned) | $schema->tinyInteger('status_code') |
| smallInteger | smallInteger($column) |
Small integer (16-bit) | -32,768 to 32,767 | $schema->smallInteger('port') |
| bigInteger | bigInteger($column) |
Large integer (64-bit) | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 | $schema->bigInteger('file_size') |
Practical Examples:
// User age (0-150)
$schema->tinyInteger('age')->nullable(false);
// Order quantity (thousands)
$schema->integer('quantity')->defaultValue(1);
// File size in bytes (gigabytes)
$schema->bigInteger('file_size_bytes')->nullable(false);
| Type | Method | Parameters | Max Size | Description | Example |
|---|---|---|---|---|---|
| string | string($column, $length) |
length (default: 255) |
255-65,535* | Variable-length string | $schema->string('email', 255) |
| text | text($column) |
None | 65,535 bytes | Standard text field | $schema->text('description') |
| tinyText | tinyText($column) |
None | 255 bytes | Very short text | $schema->tinyText('code') |
| mediumText | mediumText($column) |
None | 16,777,215 bytes | Medium text field | $schema->mediumText('article') |
| longText | longText($column) |
None | 4,294,967,295 bytes | Very large text | $schema->longText('content') |
*Maximum string length varies by database system.
Best Practices:
// Use string for indexable fields (emails, usernames)
$schema->string('email', 255)->unique();
$schema->string('username', 64)->nullable(false);
// Use text types for non-indexable content
$schema->text('bio');
$schema->longText('blog_post_content');
// Short codes or identifiers
$schema->string('country_code', 2); // 'US', 'UK', etc.
$schema->string('currency', 3); // 'USD', 'EUR', etc.
Note
You cannot add indexes totext,mediumText, orlongTextcolumns in most databases. Usestringfor fields that need indexing.
| Type | Method | Parameters | Description | Example |
|---|---|---|---|---|
| decimal | decimal($column, $precision, $scale) |
precision: total digits, scale: decimal places |
Exact fixed-point number | $schema->decimal('price', 10, 2) |
| float | float($column) |
None | Single-precision floating-point | $schema->float('rating') |
| double | double($column) |
None | Double-precision floating-point | $schema->double('coordinates') |
Choosing the Right Type:
// Money/currency - use DECIMAL for exact values
$schema->decimal('price', 10, 2); // Up to 99,999,999.99
$schema->decimal('tax_rate', 5, 4); // Up to 9.9999 (e.g., 0.0825 = 8.25%)
// Scientific calculations - use FLOAT/DOUBLE
$schema->double('latitude');
$schema->double('longitude');
$schema->float('temperature_celsius');
// Avoid FLOAT/DOUBLE for money
// ❌ BAD: $schema->float('price');
// ✅ GOOD: $schema->decimal('price', 10, 2);
| Type | Method | Parameters | Description | Example |
|---|---|---|---|---|
| datetime | datetime($column, $precision) |
precision: fractional seconds (0-6) |
Date and time with timezone handling | $schema->datetime('created_at', 6) |
| date | date($column) |
None | Date only (no time component) | $schema->date('birth_date') |
| time | time($column) |
None | Time only (no date component) | $schema->time('opening_time') |
| timestamp | timestamp($column) |
None | Unix timestamp | $schema->timestamp('updated_at') |
DateTime Precision:
// No fractional seconds (most common)
$schema->datetime('created_at');
// With microseconds (for high-precision timestamps)
$schema->datetime('event_occurred_at', 6); // Stores up to microseconds
// Different databases handle precision differently:
// - MySQL: datetime(6) stores up to 6 decimal places
// - PostgreSQL: timestamp(6) with time zone
// - SQL Server: datetime2(6) instead of datetime
Timezone Behavior:
// DBAL automatically handles UTC conversion for datetime columns
$schema->datetime('created_at')->defaultValue(AbstractColumn::DATETIME_NOW);
// Current timestamp as default
use Cycle\Database\Schema\AbstractColumn;
$schema->datetime('updated_at')->defaultValue(AbstractColumn::DATETIME_NOW);
Important
DBAL automatically forces UTC timezone fordatetimeanddatecolumns to ensure consistency across different server configurations.
| Type | Method | Max Size | Description | Example |
|---|---|---|---|---|
| binary | binary($column) |
65,535 bytes | Standard binary data | $schema->binary('file_data') |
| tinyBinary | tinyBinary($column) |
255 bytes | Small binary field | $schema->tinyBinary('icon') |
| longBinary | longBinary($column) |
4,294,967,295 bytes | Large binary data | $schema->longBinary('video') |
Practical Use Cases:
// Small images or icons
$schema->binary('avatar');
// File uploads
$schema->longBinary('document')->nullable(true);
// Hash values
$schema->binary('password_hash');
// Consider storing files externally for very large data
| Type | Method | Description | Database Support | Example |
|---|---|---|---|---|
| boolean | boolean($column) |
True/false values (stored as 1/0 in most databases) | All databases | $schema->boolean('is_active') |
| json | json($column) |
JSON data structure | Native in PostgreSQL, emulated as text in others | $schema->json('metadata') |
| uuid | uuid($column) |
UUID/GUID identifier | Database-specific | $schema->uuid('external_id') |
| snowflake | snowflake($column) |
Twitter Snowflake ID | Database-specific | $schema->snowflake('distributed_id') |
| ulid | ulid($column) |
Universally Unique Lexicographically Sortable Identifier | Database-specific | $schema->ulid('sortable_id') |
Boolean Usage:
// Feature flags
$schema->boolean('is_active')->defaultValue(true);
$schema->boolean('email_verified')->defaultValue(false);
// Permissions
$schema->boolean('can_edit')->nullable(false);
JSON Usage:
// Structured metadata
$schema->json('user_preferences');
$schema->json('api_response');
// Default JSON value
$schema->json('settings')->defaultValue([
'theme' => 'light',
'notifications' => true
]);
Enums provide a way to restrict column values to a predefined set. MySQL supports enums natively; other databases emulate them using string columns with CHECK constraints.
Basic Enum Declaration:
// Define allowed values
$schema->column('status')->enum(['active', 'pending', 'disabled']);
// Using shortcut
$schema->enum('priority', ['low', 'medium', 'high', 'urgent']);
With Default Values:
$schema->enum('status', ['active', 'pending', 'disabled'])
->defaultValue('pending');
$schema->enum('role', ['user', 'admin', 'moderator'])
->defaultValue('user')
->nullable(false);
Modifying Enum Values:
// You can add or remove values by redefining the enum
$schema = $database->table('users')->getSchema();
// Add new status value
$schema->enum('status', ['active', 'pending', 'disabled', 'archived'])
->defaultValue('pending');
$schema->save(); // DBAL handles the modification
Best Practices:
// ✅ GOOD: Use enums for fixed, small sets of values
$schema->enum('status', ['draft', 'published', 'archived']);
$schema->enum('gender', ['male', 'female', 'other']);
// ❌ AVOID: Large or frequently changing value sets
// Instead, use a lookup table with foreign key
By default, all columns are nullable. Use nullable() to change this:
// Allow NULL values (default)
$schema->string('middle_name')->nullable(true);
// Require non-NULL values
$schema->string('email')->nullable(false);
$schema->integer('user_id')->nullable(false);
// When adding NOT NULL columns to existing tables with data
$schema->string('phone')
->nullable(false)
->defaultValue(''); // Provides value for existing rows
Set default values for columns:
// Scalar defaults
$schema->integer('views')->defaultValue(0);
$schema->boolean('is_active')->defaultValue(true);
$schema->string('status')->defaultValue('pending');
// NULL as default
$schema->string('optional_field')->defaultValue(null);
// Database functions (datetime)
use Cycle\Database\Schema\AbstractColumn;
$schema->datetime('created_at')->defaultValue(AbstractColumn::DATETIME_NOW);
// JSON defaults
$schema->json('settings')->defaultValue([
'notifications' => true,
'theme' => 'light'
]);
Remove Default Value:
$schema->string('status')->defaultValue(null); // Remove default
Combine multiple attributes:
$schema->string('username', 64)
->nullable(false)
->unique();
$schema->decimal('balance', 10, 2)
->nullable(false)
->defaultValue(0.00);
$schema->datetime('created_at', 6)
->nullable(false)
->defaultValue(AbstractColumn::DATETIME_NOW);
$schema->enum('status', ['active', 'inactive'])
->nullable(false)
->defaultValue('active')
->index(); // Add simple index
The simplest approach uses primary() or bigPrimary():
$schema = $database->table('users')->getSchema();
// Standard auto-increment (32-bit)
$schema->primary('id');
// Or 64-bit for very large tables
$schema->bigPrimary('id');
$schema->save();
Generated SQL (MySQL):
CREATE TABLE users
(
id INT (11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
) ENGINE=InnoDB;
Create primary keys from multiple columns:
$schema = $database->table('user_roles')->getSchema();
$schema->integer('user_id')->nullable(false);
$schema->integer('role_id')->nullable(false);
// Define composite primary key
$schema->setPrimaryKeys(['user_id', 'role_id']);
$schema->save();
Generated SQL (MySQL):
CREATE TABLE ` user_roles `
(
`
user_id
`
INT
(
11
) NOT NULL,
` role_id ` INT
(
11
) NOT NULL,
PRIMARY KEY
(
`
user_id
`,
`
role_id
`
)
) ENGINE=InnoDB;
$schema = $database->table('countries')->getSchema();
// String primary key
$schema->string('code', 2)->nullable(false);
$schema->string('name', 255);
$schema->setPrimaryKeys(['code']);
$schema->save();
Important
Primary keys can only be set during table creation. You cannot change them after the table exists.
Create indexes for query performance:
$schema = $database->table('users')->getSchema();
$schema->primary('id');
$schema->string('email', 255);
$schema->string('username', 64);
$schema->datetime('created_at');
// Method 1: Using index() method
$schema->index(['email']);
// Method 2: Inline with column definition
$schema->column('username')->index();
$schema->save();
Enforce uniqueness constraints:
// Unique constraint on single column
$schema->string('email', 255)->unique();
// Or using index() method
$schema->string('username', 64);
$schema->index(['username'])->unique(true);
$schema->save();
Index multiple columns together:
$schema = $database->table('posts')->getSchema();
$schema->primary('id');
$schema->integer('user_id');
$schema->datetime('created_at');
$schema->string('status', 20);
// Composite index for common queries
$schema->index(['user_id', 'created_at']);
$schema->index(['status', 'created_at']);
// Composite unique index
$schema->string('slug', 255);
$schema->integer('category_id');
$schema->index(['slug', 'category_id'])->unique(true);
$schema->save();
Some databases support specifying sort order in indexes:
// Ascending order (default)
$schema->index(['created_at ASC']);
// Descending order
$schema->index(['created_at DESC']);
// Mixed sorting in composite index
$schema->index(['status ASC', 'created_at DESC']);
Note
Not all databases support column sorting in indexes. DBAL will throw aDriverExceptionif the feature is not supported.
Change index properties:
$schema = $database->table('users')->getSchema();
// Make existing index unique
$schema->index(['email'])->unique(true);
// Remove unique constraint
$schema->index(['email'])->unique(false);
$schema->save();
Important Restrictions:
// ❌ CANNOT index text/binary columns
// $schema->text('description')->index(); // Will fail
// ✅ Use string for indexable text
$schema->string('title', 255)->index();
// ❌ CANNOT create unique index on table with duplicate data
// Must clean data first
// ✅ Consider index size limits (database-specific)
// MySQL: ~767 bytes for InnoDB
$schema->string('long_field', 1000)->index(); // May fail on some engines
Link tables together with referential integrity:
// Parent table
$users = $database->table('users')->getSchema();
$users->primary('id');
$users->string('name', 64);
$users->save();
// Child table with foreign key
$posts = $database->table('posts')->getSchema();
$posts->primary('id');
$posts->string('title', 255);
$posts->integer('user_id');
// Create foreign key
$posts->foreign('user_id')->references('users', 'id');
$posts->save();
Generated SQL (MySQL):
ALTER TABLE posts
ADD CONSTRAINT posts_foreign_user_id_5f2a8b9c
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE NO ACTION
ON UPDATE NO ACTION;
Control behavior when referenced records change:
$posts = $database->table('posts')->getSchema();
$posts->integer('user_id');
$foreignKey = $posts->foreign('user_id')->references('users', 'id');
// Delete posts when user is deleted
$foreignKey->onDelete(ForeignKeyInterface::CASCADE);
// Update foreign key when parent key changes
$foreignKey->onUpdate(ForeignKeyInterface::CASCADE);
$posts->save();
Available Actions:
| Action | Constant | Description |
|---|---|---|
| NO ACTION | ForeignKeyInterface::NO_ACTION |
Prevent deletion/update if child records exist (default) |
| CASCADE | ForeignKeyInterface::CASCADE |
Delete/update child records automatically |
| SET NULL | ForeignKeyInterface::SET_NULL |
Set foreign key to NULL (requires nullable column) |
| RESTRICT | ForeignKeyInterface::RESTRICT |
Similar to NO ACTION, checked immediately |
Complete Example:
use Cycle\Database\ForeignKeyInterface;
// Posts are deleted when user is deleted
$posts->integer('author_id')->nullable(false);
$posts->foreign('author_id')
->references('users', 'id')
->onDelete(ForeignKeyInterface::CASCADE)
->onUpdate(ForeignKeyInterface::CASCADE);
// Comments orphaned when post is deleted
$comments->integer('post_id')->nullable(true);
$comments->foreign('post_id')
->references('posts', 'id')
->onDelete(ForeignKeyInterface::SET_NULL)
->onUpdate(ForeignKeyInterface::CASCADE);
// Orders cannot be deleted if they have items
$orderItems->integer('order_id')->nullable(false);
$orderItems->foreign('order_id')
->references('orders', 'id')
->onDelete(ForeignKeyInterface::RESTRICT)
->onUpdate(ForeignKeyInterface::CASCADE);
Reference multiple columns:
// Parent table with composite primary key
$userProducts = $database->table('user_products')->getSchema();
$userProducts->integer('user_id')->nullable(false);
$userProducts->integer('product_id')->nullable(false);
$userProducts->setPrimaryKeys(['user_id', 'product_id']);
$userProducts->save();
// Child table referencing composite key
$favorites = $database->table('favorites')->getSchema();
$favorites->primary('id');
$favorites->integer('user_id');
$favorites->integer('product_id');
$favorites->foreign(['user_id', 'product_id'])
->references('user_products', ['user_id', 'product_id'])
->onDelete(ForeignKeyInterface::CASCADE);
$favorites->save();
By default, foreign keys create an index. Disable if needed:
$posts->integer('user_id');
$posts->foreign('user_id', false) // false = don't create index
->references('users', 'id');
// Some databases (SQL Server) restrict CASCADE actions
// to avoid circular references. Plan your schema accordingly.
// Example: Cannot have mutual CASCADE relationships
// Table A -> CASCADE -> Table B
// Table B -> CASCADE -> Table A // May be rejected
$schema = $database->table('users')->getSchema();
// Method 1: Using setName()
$schema->column('email')->setName('email_address');
// Method 2: Using renameColumn()
$schema->renameColumn('old_name', 'new_name');
$schema->save();
Generated SQL (MySQL):
ALTER TABLE users
CHANGE email email_address VARCHAR (255) NULL;
$schema = $database->table('old_users')->getSchema();
$schema->setName('users');
$schema->save();
Generated SQL:
ALTER TABLE old_users
RENAME TO users;
$schema = $database->table('users')->getSchema();
$schema->renameIndex(
['email'], // Column(s) that form the index
'idx_user_email' // New index name
);
$schema->save();
$schema = $database->table('users')->getSchema();
$schema->dropColumn('middle_name');
$schema->dropColumn('legacy_field');
$schema->save();
Generated SQL:
ALTER TABLE users
DROP COLUMN middle_name;
ALTER TABLE users
DROP COLUMN legacy_field;
$schema = $database->table('posts')->getSchema();
// Drop index by column(s)
$schema->dropIndex(['title']);
$schema->dropIndex(['user_id', 'created_at']); // Composite index
$schema->save();
$schema = $database->table('posts')->getSchema();
// Drop by column(s)
$schema->dropForeignKey(['user_id']);
$schema->save();
Important Order:
// Always drop foreign keys before dropping referenced columns
$schema = $database->table('posts')->getSchema();
// 1. Drop foreign key first
$schema->dropForeignKey(['user_id']);
// 2. Then drop the column
$schema->dropColumn('user_id');
$schema->save();
$schema = $database->table('old_table')->getSchema();
$schema->declareDropped();
$schema->save();
Reset table schema to only contain declared elements:
$schema = $database->table('users')->getSchema();
// Clear current schema state
$schema->setState(null);
// Now redefine only the columns you want
$schema->primary('id');
$schema->string('email', 255);
$schema->string('username', 64);
// All other columns will be dropped
$schema->save();
Use Cases:
Warning
This will drop any columns not declared in your schema. Use with caution on production databases.
Inspect schema changes before applying them:
$schema = $database->table('users')->getSchema();
// Make some changes
$schema->string('new_field');
$schema->dropColumn('old_field');
$schema->index(['email'])->unique(true);
// Get comparator to inspect changes
$comparator = $schema->getComparator();
// Check what will be added
$addedColumns = $comparator->addedColumns();
foreach ($addedColumns as $column) {
echo "Will add column: " . $column->getName() . "\n";
}
// Check what will be dropped
$droppedColumns = $comparator->droppedColumns();
foreach ($droppedColumns as $column) {
echo "Will drop column: " . $column->getName() . "\n";
}
// Check what will be modified
$alteredColumns = $comparator->alteredColumns();
foreach ($alteredColumns as [$new, $old]) {
echo "Will modify column: " . $new->getName() . "\n";
}
// Check if there are any changes
if ($comparator->hasChanges()) {
echo "Schema has pending changes\n";
// Optionally save
$schema->save();
} else {
echo "Schema is up to date\n";
}
Comparator Methods:
| Method | Returns | Description |
|---|---|---|
hasChanges() |
bool |
Whether any changes exist |
isRenamed() |
bool |
Whether table is renamed |
isPrimaryChanged() |
bool |
Whether primary key changed |
addedColumns() |
AbstractColumn[] |
New columns to create |
droppedColumns() |
AbstractColumn[] |
Columns to remove |
alteredColumns() |
array |
Modified columns (returns pairs of [new, old]) |
addedIndexes() |
AbstractIndex[] |
New indexes to create |
droppedIndexes() |
AbstractIndex[] |
Indexes to remove |
alteredIndexes() |
array |
Modified indexes |
addedForeignKeys() |
AbstractForeignKey[] |
New foreign keys to create |
droppedForeignKeys() |
AbstractForeignKey[] |
Foreign keys to remove |
alteredForeignKeys() |
array |
Modified foreign keys |
Generate Migration Code:
$comparator = $schema->getComparator();
// Use comparator data to generate migration files
foreach ($comparator->addedColumns() as $column) {
$migrationCode = sprintf(
'$schema->%s(\'%s\');',
$column->getAbstractType(),
$column->getName()
);
// Write to migration file...
}
When creating multiple related tables, use Reflector to handle dependencies automatically:
use Cycle\Database\Schema\Reflector;
// Define first table
$users = $database->table('users')->getSchema();
$users->primary('id');
$users->string('username', 64);
// Define related table
$posts = $database->table('posts')->getSchema();
$posts->primary('id');
$posts->string('title', 255);
$posts->integer('user_id');
$posts->foreign('user_id')->references('users', 'id');
// Define another related table
$comments = $database->table('comments')->getSchema();
$comments->primary('id');
$comments->text('content');
$comments->integer('post_id');
$comments->foreign('post_id')->references('posts', 'id');
// Create reflector and add tables
$reflector = new Reflector();
$reflector->addTable($users);
$reflector->addTable($posts);
$reflector->addTable($comments);
// Reflector automatically orders tables by dependencies
// and syncs them in the correct order
$reflector->run();
How It Works:
Manual Ordering (if needed):
$reflector = new Reflector();
$reflector->addTable($users);
$reflector->addTable($posts);
$reflector->addTable($comments);
// Get tables in dependency order
$sortedTables = $reflector->sortedTables();
foreach ($sortedTables as $table) {
echo "Processing: " . $table->getName() . "\n";
}
Benefits of Using Reflector:
Example with Multiple Databases:
$dbPrimary = $dbal->database('primary');
$dbSecondary = $dbal->database('secondary');
$reflector = new Reflector();
// Add tables from different databases
$reflector->addTable($dbPrimary->table('users')->getSchema());
$reflector->addTable($dbPrimary->table('posts')->getSchema());
$reflector->addTable($dbSecondary->table('logs')->getSchema());
// Reflector handles multiple database transactions
$reflector->run();
Choose the smallest type that fits your data:
// ✅ GOOD: Right-sized types
$schema->tinyInteger('age'); // 0-255
$schema->string('country_code', 2); // 'US', 'UK'
$schema->decimal('price', 10, 2); // Exact money values
// ❌ AVOID: Oversized types
$schema->bigInteger('age'); // Wastes space
$schema->string('country_code', 255); // Wastes space
$schema->float('price'); // Imprecise for money
// ✅ GOOD: Index frequently queried columns
$schema->string('email', 255)->unique();
$schema->index(['user_id', 'created_at']); // For sorting
$schema->index(['status', 'priority']); // For filtering
// ❌ AVOID: Over-indexing
// Don't index columns rarely used in queries
// Don't index large text fields
// ✅ GOOD: Clear defaults and null handling
$schema->boolean('is_active')->defaultValue(true)->nullable(false);
$schema->integer('view_count')->defaultValue(0)->nullable(false);
$schema->datetime('created_at')->defaultValue(AbstractColumn::DATETIME_NOW);
// ❌ AVOID: Ambiguous nullable booleans
$schema->boolean('is_verified')->nullable(true); // NULL vs false?
// ✅ GOOD: Enforce referential integrity
$schema->foreign('user_id')
->references('users', 'id')
->onDelete(ForeignKeyInterface::CASCADE);
// ❌ AVOID: Orphaned records
// Without FK, you may have posts pointing to non-existent users
// ✅ GOOD: Most selective column first
$schema->index(['status', 'created_at', 'user_id']);
// Useful for: WHERE status = ? ORDER BY created_at
// ❌ AVOID: Poor ordering
$schema->index(['created_at', 'status']);
// Can't efficiently filter by status alone
// ✅ GOOD: Multiple related schemas
$reflector = new Reflector();
$reflector->addTable($users);
$reflector->addTable($posts);
$reflector->run(); // All or nothing
// ❌ AVOID: Individual saves
$users->save();
$posts->save(); // May fail if users didn't save
// ✅ GOOD: Check before saving
$comparator = $schema->getComparator();
if ($comparator->droppedColumns()) {
// Confirm you want to drop these columns
foreach ($comparator->droppedColumns() as $col) {
echo "WARNING: Will drop " . $col->getName() . "\n";
}
}
$schema->save();
For production applications, use migrations instead of direct schema saves:
// ✅ GOOD: Generate migrations for review
// See the Migrations documentation for details
// ❌ AVOID: Direct schema saves in production
// $schema->save(); // Skip in production, use migrations
// ✅ GOOD: Add comments for clarity
$schema->string('status', 20)
->enum(['draft', 'review', 'published', 'archived'])
->defaultValue('draft');
// Status workflow: draft -> review -> published -> archived
$schema->decimal('tax_rate', 5, 4);
// Example: 0.0825 for 8.25% tax rate
// ✅ GOOD: Test on development/staging first
// 1. Test schema changes locally
// 2. Verify in staging environment
// 3. Back up production before applying
// 4. Have rollback plan ready
// ❌ AVOID: Untested production changes
When altering column types, be aware of database-specific conversion limitations:
Safe Conversions:
integer → bigInteger
string(64) → string(128) (increasing length)nullable(true) → nullable(false) (with default or data cleanup)Potentially Unsafe:
string → integer (data loss if non-numeric values exist)bigInteger → integer (data loss if values exceed range)string(128) → string(64) (data truncation)Database-Specific:
Always test schema changes in a non-production environment first.