From laravel-react
Design database schemas with Laravel migrations, Eloquent relationships, and indexing strategies. Use when creating migrations, designing table schemas, defining relationships between models, optimizing queries with indexes, or planning database architecture. Triggers on migration, database schema, table design, foreign key, index, relationship, Eloquent, or query optimization.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel-react:database-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Laravel uses snake_case naming for migration files. The artisan generator infers the
Laravel uses snake_case naming for migration files. The artisan generator infers the table name from the migration name:
# Creating tables
php artisan make:migration create_orders_table
php artisan make:migration create_order_items_table
# Modifying tables
php artisan make:migration add_status_to_orders_table
php artisan make:migration add_tracking_number_to_orders_table
php artisan make:migration rename_title_to_name_on_products_table
php artisan make:migration drop_legacy_column_from_users_table
Each migration should do one logical thing. This makes rollbacks predictable:
// Good: single responsibility
// create_orders_table migration
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->constrained();
$table->string('order_number')->unique();
$table->enum('status', ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'])
->default('pending');
$table->decimal('subtotal', 10, 2);
$table->decimal('tax', 10, 2)->default(0);
$table->decimal('total', 10, 2);
$table->text('notes')->nullable();
$table->timestamp('shipped_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
The down() method must reverse exactly what up() does. This allows safe rollbacks:
// add_status_to_orders_table
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('status')->default('pending')->after('total');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('status');
});
}
Always use the built-in helpers for consistency:
$table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
$table->uuid('id')->primary(); // UUID primary key alternative
$table->ulid('id')->primary(); // ULID primary key alternative
$table->timestamps(); // created_at and updated_at TIMESTAMP NULLABLE
$table->softDeletes(); // deleted_at TIMESTAMP NULLABLE
$table->rememberToken(); // remember_token VARCHAR(100) NULLABLE
| Migration Method | MySQL Type | PostgreSQL Type | Purpose |
|---|---|---|---|
$table->id() | BIGINT UNSIGNED AI PK | BIGSERIAL PK | Auto-incrementing primary key |
$table->uuid('id') | CHAR(36) | UUID | UUID column |
$table->ulid('id') | CHAR(26) | CHAR(26) | ULID column |
$table->string('name') | VARCHAR(255) | VARCHAR(255) | Short text (names, emails) |
$table->string('code', 10) | VARCHAR(10) | VARCHAR(10) | Short text with max length |
$table->text('body') | TEXT | TEXT | Long text (descriptions) |
$table->mediumText('content') | MEDIUMTEXT | TEXT | Medium-length content |
$table->longText('payload') | LONGTEXT | TEXT | Very long content (JSON blobs) |
$table->integer('qty') | INT | INTEGER | Standard integer |
$table->bigInteger('views') | BIGINT | BIGINT | Large integer |
$table->unsignedInteger('qty') | INT UNSIGNED | INTEGER | Non-negative integer |
$table->tinyInteger('level') | TINYINT | SMALLINT | Small integer (0-127) |
$table->boolean('active') | TINYINT(1) | BOOLEAN | True/false flag |
$table->decimal('price', 10, 2) | DECIMAL(10,2) | NUMERIC(10,2) | Exact decimal (money) |
$table->float('rating') | FLOAT | REAL | Approximate decimal |
$table->date('birth_date') | DATE | DATE | Date without time |
$table->dateTime('starts_at') | DATETIME | TIMESTAMP | Date with time |
$table->timestamp('sent_at') | TIMESTAMP | TIMESTAMP | Timestamp |
$table->time('opens_at') | TIME | TIME | Time without date |
$table->year('vintage') | YEAR | INTEGER | Year only |
$table->json('metadata') | JSON | JSONB | JSON data |
$table->enum('status', [...]) | ENUM | VARCHAR + CHECK | Enumerated values |
$table->binary('photo') | BLOB | BYTEA | Binary data |
$table->ipAddress('visitor') | VARCHAR(45) | INET | IPv4 or IPv6 address |
$table->macAddress('device') | VARCHAR(17) | MACADDR | MAC address |
$table->string('email')->unique(); // Unique constraint
$table->string('nickname')->nullable(); // Allow NULL
$table->string('status')->default('active'); // Default value
$table->integer('position')->unsigned(); // Unsigned (non-negative)
$table->text('bio')->nullable()->after('email'); // Column ordering (MySQL)
$table->string('slug')->comment('URL slug'); // Column comment
$table->string('legacy')->virtualAs("CONCAT(first_name, ' ', last_name)"); // Generated column
Add indexes on columns that appear in:
WHERE clauses (equality and range lookups)JOIN conditions (foreign keys)ORDER BY clauses (sorting)GROUP BY clauses (aggregation)Do NOT index columns that:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamp('created_at');
// Single column index
$table->index('status');
// Composite index (column order matters — leftmost prefix rule)
$table->index(['user_id', 'status', 'created_at']);
// Unique constraint (also creates an index)
$table->unique('order_number');
// Composite unique
$table->unique(['user_id', 'product_id']);
});
The leftmost prefix rule means a composite index (user_id, status, created_at) can
satisfy queries that filter on:
user_id aloneuser_id AND statususer_id AND status AND created_atIt will NOT help queries that filter on:
status alone (needs its own index)created_at alonePut the most selective column first, and the range column last.
$table->fullText('body'); // Single column
$table->fullText(['title', 'body']); // Multi-column
// Querying
Product::whereFullText('description', 'wireless headphones')->get();
Product::whereFullText(['title', 'description'], 'wireless headphones')->get();
// add_index_to_orders_table migration
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index('status', 'orders_status_index');
$table->index(['user_id', 'created_at'], 'orders_user_created_index');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_status_index');
$table->dropIndex('orders_user_created_index');
});
}
// Shorthand (Laravel convention: column name infers table)
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Explicit table reference
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
// Nullable foreign key
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
| Method | SQL | Behavior |
|---|---|---|
cascadeOnDelete() | ON DELETE CASCADE | Delete child rows when parent is deleted |
nullOnDelete() | ON DELETE SET NULL | Set foreign key to NULL (column must be nullable) |
restrictOnDelete() | ON DELETE RESTRICT | Prevent parent deletion if children exist |
noActionOnDelete() | ON DELETE NO ACTION | Same as RESTRICT in most databases |
$table->foreignId('user_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete();
// Migration: create_profiles_table
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('bio')->nullable();
$table->string('avatar_url')->nullable();
$table->timestamps();
// User model
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
// Profile model
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Usage
$user->profile;
$user->profile()->create(['bio' => 'Developer']);
$profile->user;
// Migration: create_order_items_table
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->decimal('unit_price', 10, 2);
$table->timestamps();
// Order model
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
// OrderItem model
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
// Usage
$order->items;
$order->items()->where('quantity', '>', 1)->get();
$order->items()->create([...]);
// Migration: create_product_tag_table (pivot)
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['product_id', 'tag_id']);
// Product model
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
// Tag model
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)->withTimestamps();
}
// Usage
$product->tags()->attach([1, 2, 3]);
$product->tags()->detach([2]);
$product->tags()->sync([1, 3, 5]); // replaces all
$product->tags()->syncWithoutDetaching([4]); // adds without removing
// Country -> User -> Post
// Country model
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
// Access all posts from a country without manually joining
$country->posts;
// Migration: create_comments_table
$table->id();
$table->morphs('commentable'); // creates commentable_type and commentable_id columns
$table->foreignId('user_id')->constrained();
$table->text('body');
$table->timestamps();
// Post model
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
// Video model
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
// Comment model
public function commentable(): MorphTo
{
return $this->morphTo();
}
// Register morph map in AppServiceProvider::boot()
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
]);
// Migration: create_taggables_table
$table->id();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->unique(['tag_id', 'taggable_type', 'taggable_id']);
// Post model
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
// Video model
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
// Tag model
public function posts(): MorphedByMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function videos(): MorphedByMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
// Migration
Schema::create('order_product', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity')->default(1);
$table->decimal('unit_price', 10, 2);
$table->timestamps();
$table->unique(['order_id', 'product_id']);
});
// Order model
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('quantity', 'unit_price')
->withTimestamps();
}
// Accessing pivot data
foreach ($order->products as $product) {
echo $product->pivot->quantity;
echo $product->pivot->unit_price;
}
// Attaching with extra data
$order->products()->attach($productId, [
'quantity' => 2,
'unit_price' => 29.99,
]);
// app/Models/OrderProduct.php
use Illuminate\Database\Eloquent\Relations\Pivot;
class OrderProduct extends Pivot
{
protected $casts = [
'unit_price' => 'decimal:2',
];
public function getLineTotalAttribute(): float
{
return $this->quantity * $this->unit_price;
}
}
// Order model
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->using(OrderProduct::class)
->withPivot('quantity', 'unit_price')
->withTimestamps();
}
// Migration
$table->softDeletes(); // adds deleted_at TIMESTAMP NULLABLE
// Model
use Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes;
}
Order::all(); // excludes soft-deleted (default)
Order::withTrashed()->get(); // includes soft-deleted
Order::onlyTrashed()->get(); // only soft-deleted
$order->trashed(); // check if soft-deleted
$order->restore(); // restore soft-deleted record
$order->forceDelete(); // permanently delete
A unique constraint on email will conflict with soft-deleted records. Solutions:
// Option 1: Partial unique index (PostgreSQL only)
DB::statement('CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL');
// Option 2: Include deleted_at in composite unique (MySQL)
$table->unique(['email', 'deleted_at']);
// Option 3: Nullify the unique field on soft delete
public static function booted(): void
{
static::softDeleted(function (User $user) {
$user->updateQuietly(['email' => $user->email . '::deleted::' . $user->id]);
});
}
Use soft deletes when:
Avoid soft deletes when:
use Illuminate\Support\Facades\DB;
$order = DB::transaction(function () use ($request) {
$order = Order::create([
'user_id' => auth()->id(),
'total' => 0,
]);
foreach ($request->items as $item) {
$product = Product::lockForUpdate()->findOrFail($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new InsufficientStockException($product);
}
$product->decrement('stock', $item['quantity']);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price' => $product->price,
]);
}
$order->update(['total' => $order->items->sum(fn ($i) => $i->quantity * $i->unit_price)]);
return $order;
}, attempts: 3); // retries on deadlock
DB::beginTransaction();
try {
$order = Order::create([...]);
$order->items()->createMany([...]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
DB::transaction(function () {
Order::create([...]);
DB::transaction(function () {
// This creates a savepoint
// If this inner block fails, only this portion rolls back
Payment::create([...]);
});
});
// Bad: N+1 problem (1 query for orders + N queries for customers)
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // lazy-loads customer each iteration
}
// Good: Eager load (2 queries total)
$orders = Order::with('customer')->get();
// Nested eager loading
$orders = Order::with('customer', 'items.product')->get();
// Constrained eager loading
$orders = Order::with(['items' => function ($query) {
$query->where('quantity', '>', 1)->orderBy('unit_price', 'desc');
}])->get();
// Lazy eager loading (when you already have the collection)
$orders = Order::all();
$orders->load('customer');
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
This throws an exception whenever a relationship is lazy-loaded, forcing you to use eager loading everywhere.
// Add computed data without loading the relationship
$users = User::query()
->addSelect([
'last_order_at' => Order::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])
->get();
// withCount
$orders = Order::withCount('items')->get();
echo $orders->first()->items_count;
// withSum, withAvg, withMin, withMax
$orders = Order::withSum('items', 'quantity')
->withAvg('items', 'unit_price')
->get();
echo $orders->first()->items_sum_quantity;
echo $orders->first()->items_avg_unit_price;
// Process records in chunks (memory-efficient)
Order::where('status', 'pending')->chunk(500, function ($orders) {
foreach ($orders as $order) {
$order->processReminder();
}
});
// Lazy collection (even more memory-efficient for iteration)
Order::where('status', 'pending')->lazy()->each(function ($order) {
$order->processReminder();
});
// Chunk by ID (safer for updates)
Order::where('status', 'pending')->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$order->update(['reminded_at' => now()]);
}
});
Use raw queries sparingly and only when Eloquent cannot express the logic efficiently:
// Complex aggregation
$stats = DB::select(<<<'SQL'
SELECT
DATE(created_at) as date,
COUNT(*) as total_orders,
SUM(total) as revenue,
AVG(total) as avg_order_value
FROM orders
WHERE created_at >= ?
GROUP BY DATE(created_at)
ORDER BY date DESC
SQL, [now()->subDays(30)]);
// Using selectRaw within Eloquent
$orders = Order::query()
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupByRaw('DATE(created_at)')
->get();
// Enable query log in development
DB::enableQueryLog();
// ... run queries ...
dd(DB::getQueryLog()); // see all executed queries with bindings and time
# Run all pending migrations
php artisan migrate
# Rollback last batch
php artisan migrate:rollback
# Rollback all and re-run
php artisan migrate:fresh # drops all tables, runs all migrations
php artisan migrate:fresh --seed # also runs seeders
# Check migration status
php artisan migrate:status
# Generate a migration
php artisan make:migration create_orders_table
# Generate a model with migration, factory, seeder, controller, and form requests
php artisan make:model Order -mfscR
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-reactComplete Eloquent ORM - models, relationships, queries, casts, observers, factories. Use when working with database models.
Guides schema design, migration safety, and ORM analysis. Detects database engine and ORM, evaluates normalization and indexing, and validates backward compatibility.
Provides best practices for database schema design, query optimization, migrations, indexing, N+1 avoidance, pagination, and transactions across PostgreSQL, MySQL, Oracle, SQLite. Use for schema design, queries, or migrations.