Laravel Core

Database Standards

Migrations

Basic Structure

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tickets', function (Blueprint $table): void {
            $table->id();
            $table->string('hash_id')->nullable()->default(null)->unique();
            $table->string('title');
            $table->text('body');
            $table->boolean('is_active')->default(true);
            $table->json('metadata')->nullable()->default(null);
            $table->timestampTz('due_at')->nullable();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('category_id')->nullable()->constrained();
            $table->softDeletes();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tickets');
    }
};

Column Naming

Standard Columns

Type Convention Examples
Primary Key id id
Hash ID hash_id hash_id
Foreign Key {model}_id user_id, category_id, folder_id
Boolean is_ or has_ prefix is_active, is_admin, has_subscription
Timestamps _at suffix created_at, due_at, published_at
URLs url or _url suffix url, map_url, google_maps_url
Counts _count suffix views_count, likes_count

Boolean Columns

Always prefix with is_ or has_:

// Good
$table->boolean('is_active')->default(true);
$table->boolean('is_admin')->default(false);
$table->boolean('is_verified')->default(false);
$table->boolean('has_subscription')->default(false);
$table->boolean('has_2fa')->default(false);

// Bad
$table->boolean('active');
$table->boolean('admin');
$table->boolean('verified');

Timestamp Columns

// Standard Laravel timestamps
$table->timestamps();        // created_at, updated_at
$table->softDeletes();       // deleted_at

// Custom timestamps with timezone
$table->timestampTz('due_at')->nullable();
$table->timestampTz('published_at')->nullable();
$table->timestampTz('next_reminder_at')->nullable();
$table->timestampTz('started_at')->nullable();
$table->timestampTz('completed_at')->nullable();

Foreign Keys

With Constraints

// Standard - cascading delete
$table->foreignId('user_id')->constrained()->cascadeOnDelete();

// Nullable foreign key
$table->foreignId('category_id')->nullable()->constrained();

// With specific actions
$table->foreignId('parent_id')
    ->nullable()
    ->constrained('categories')
    ->nullOnDelete();

// Custom table reference
$table->foreignId('assigned_to')
    ->nullable()
    ->constrained('users')
    ->nullOnDelete();

Naming Convention

  • Always use {related_model_singular}_id
  • Use the model name, not the table name
// Good
$table->foreignId('user_id');        // references users table
$table->foreignId('category_id');    // references categories table
$table->foreignId('ticket_id');      // references tickets table

// Bad
$table->foreignId('users_id');       // Plural
$table->foreignId('userId');         // camelCase

Indexes

When to Add Indexes

// Columns frequently used in WHERE clauses
$table->index('is_active');
$table->index('status');

// Columns used in ORDER BY
$table->index('created_at');
$table->index('position');

// Composite indexes for common query patterns
$table->index(['user_id', 'is_active']);
$table->index(['user_id', 'folder_id', 'is_active']);

// Unique constraints
$table->unique('email');
$table->unique('hash_id');
$table->unique(['name', 'user_id']);  // Composite unique
$table->unique(['name', 'user_id', 'folder_id']);

JSON Columns

// Nullable JSON with default null
$table->json('settings')->nullable()->default(null);
$table->json('metadata')->nullable()->default(null);
$table->json('custom_fields')->nullable()->default(null);
$table->json('frequency_data')->nullable()->default(null);

// Cast in model
protected function casts(): array
{
    return [
        'settings' => 'array',
        'metadata' => 'array',
        'frequency_data' => RepeatingFrequencyData::class,
    ];
}

Common Patterns

Hash ID Pattern

// In migration
$table->string('hash_id')->nullable()->default(null)->unique();

// In model trait
trait HasHashIds
{
    protected static function boot(): void
    {
        parent::boot();

        static::created(function ($model): void {
            if ($model->hash_id) {
                return;
            }

            $reflect = new ReflectionClass(self::class);
            $connection = Str::lower($reflect->getShortName());

            $model->hash_id = Hashids::connection($connection)
                ->encode((string) $model->id);
            $model->save();
        });
    }
}

Soft Deletes

// In migration
$table->softDeletes();

// In model
use Illuminate\Database\Eloquent\SoftDeletes;

final class Ticket extends Model
{
    use SoftDeletes;
}

Position/Ordering

// In migration
$table->unsignedInteger('position')->default(0);

// In relationship
public function items(): HasMany
{
    return $this->hasMany(Item::class)
        ->orderBy('position')
        ->orderBy('created_at');
}

Query Patterns

Use Eloquent Over Raw Queries

// Good - Eloquent
$tickets = Ticket::query()
    ->where('is_active', true)
    ->where('user_id', $userId)
    ->orderBy('created_at', 'desc')
    ->get();

// Good - With scopes
$tickets = Ticket::active()
    ->forUser($user)
    ->dueThisWeek()
    ->get();

// Avoid unless necessary
$tickets = DB::select('SELECT * FROM tickets WHERE is_active = ?', [true]);

Eager Loading

// Good - Prevent N+1
$tickets = Ticket::with(['user', 'category', 'items'])->get();

// Good - Nested eager loading
$tickets = Ticket::with([
    'user',
    'items.tags',
    'category.parent',
])->get();

// Good - Conditional eager loading
$tickets = Ticket::with(['items' => function ($query) {
    $query->where('is_active', true);
}])->get();

Chunking for Large Datasets

// Good - Memory efficient
Ticket::query()
    ->where('is_active', true)
    ->chunk(100, function ($tickets) {
        foreach ($tickets as $ticket) {
            // Process ticket
        }
    });

// For updates
Ticket::query()
    ->where('status', 'pending')
    ->chunkById(100, function ($tickets) {
        foreach ($tickets as $ticket) {
            $ticket->update(['status' => 'processed']);
        }
    });

Migration Best Practices

Modifying Existing Tables

// Adding columns
public function up(): void
{
    Schema::table('tickets', function (Blueprint $table): void {
        $table->string('priority')->default('normal')->after('status');
        $table->boolean('is_featured')->default(false)->after('is_active');
    });
}

public function down(): void
{
    Schema::table('tickets', function (Blueprint $table): void {
        $table->dropColumn(['priority', 'is_featured']);
    });
}

Renaming Columns

public function up(): void
{
    Schema::table('tickets', function (Blueprint $table): void {
        $table->renameColumn('name', 'title');
    });
}

Adding Indexes to Existing Tables

public function up(): void
{
    Schema::table('tickets', function (Blueprint $table): void {
        $table->index('status');
        $table->index(['user_id', 'status']);
    });
}

public function down(): void
{
    Schema::table('tickets', function (Blueprint $table): void {
        $table->dropIndex(['status']);
        $table->dropIndex(['user_id', 'status']);
    });
}

Seeders

Basic Seeder

<?php

declare(strict_types=1);

namespace Database\Seeders;

use App\Models\Category;
use Illuminate\Database\Seeder;

final class CategorySeeder extends Seeder
{
    public function run(): void
    {
        $categories = [
            ['name' => 'Bug', 'slug' => 'bug'],
            ['name' => 'Feature', 'slug' => 'feature'],
            ['name' => 'Enhancement', 'slug' => 'enhancement'],
        ];

        foreach ($categories as $category) {
            Category::create($category);
        }
    }
}

With Factories

public function run(): void
{
    User::factory()
        ->count(10)
        ->has(Ticket::factory()->count(5))
        ->create();
}