Key Concepts

Migrations

Schema migration conventions — anonymous-class syntax, modern helpers, no speculative indexes, and never custom-name indexes or constraints.

A migration is a schema change that runs against every environment forever. Conventions matter more here than in any other layer — a migration written today will be replayed on a fresh database five years from now, and a small mistake at write time turns into a permanent quirk in the schema.

The project's overrides on top of stock Laravel are short. Use the latest syntax. Don't add indexes speculatively. Don't custom-name indexes or constraints. Each rule prevents a class of long-running bug, and together they keep migration files looking the same across the tree so reviewers can scan them quickly.

The project overrides boost's migration rules in three ways: latest syntax only, no speculative indexes, and never custom-name indexes or constraints. When the boost skill and this file disagree, this file wins.

The shape

Here is a canonical create migration that uses every helper the project standardises on.

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
            $table->foreignIdFor(Provider::class)->nullable()->constrained()->nullOnDelete();
            $table->string('reference')->unique();
            $table->timestamps();
            $table->softDeletes();
        });
    }

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

Anonymous class instead of a named one. id() for the primary key. foreignIdFor(Model::class)->constrained() for foreign keys with the cascade behaviour spelled out. timestamps() and softDeletes() for the standard audit columns. The down() drops the whole table.

Use the latest syntax

Anonymous-class migrations only. Modern column helpers: id(), foreignIdFor(Model::class)->constrained()->cascadeOnDelete(), timestamps(), softDeletes(), ulid() / uuid(). Prefer foreignIdFor() over manually wiring unsignedBigInteger plus foreign().

✅ Good — modern helpers, anonymous class.

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
            $table->timestamps();
        });
    }
};

❌ Bad — named class, manual foreign-key wiring.

class CreateOrdersTable extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('user_id');
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->timestamp('created_at')->nullable();
            $table->timestamp('updated_at')->nullable();
        });
    }
}

The bad version says the same thing in five times the code, and foreign(...)->references(...)->on(...) is easier to misspell than foreignIdFor(...)->constrained().

No indexes by default

Only add an index when there is a concrete query that filters, joins, sorts, or groups on the column. Speculative indexes slow writes and bloat storage.

❌ Bad — indexing every column "just in case".

$table->string('name')->index();
$table->string('email')->index();
$table->string('phone')->index();
$table->string('status')->index();

✅ Good — no indexes; add them later when a real query needs one.

$table->string('name');
$table->string('email')->unique();
$table->string('phone');
$table->string('status');

Every index costs write performance and storage on every row, in every backup, in every replication stream. When you later add a query that needs an index, write a small follow-up migration that just adds it — that migration sits next to the query change in code review and is easy to roll back if the query goes away.

Foreign keys are already indexed

->constrained() and foreignIdFor() create their own index. Never add an extra ->index() on a column that is also a foreign key — that produces duplicate indexes.

❌ Bad — duplicate index.

$table->foreignId('user_id')->constrained()->index();

✅ Good — the foreign key is enough.

$table->foreignIdFor(User::class)->constrained();

The duplicate version costs double the write overhead and double the storage for the same lookup.

Never custom-name indexes

Do not pass a custom name to index(), unique(), foreign(), constrained(), or dropIndex(). Let Laravel generate the default name (users_email_unique, orders_user_id_foreign).

❌ Bad — custom name on both sides.

$table->unique('email', 'my_custom_email_uniq');
$table->dropIndex('my_custom_email_uniq');

✅ Good — let Laravel generate the name.

$table->unique('email');
$table->dropUnique(['email']);

Custom names cause two problems. First, they drift between environments — an index named manually in one migration and auto-named in another produces different names for the same logical index across databases, and down() fails on one environment while working on another. Second, they break up() / down() symmetry — if up() uses a custom name but down() uses dropUnique(['email']), the drop targets the auto-generated name that doesn't exist.

This applies to every constraint-naming method: index(), unique(), foreign(), constrained(), dropIndex(), dropUnique(). If you find yourself reaching for the second argument of any of them, stop.
  • Eloquent and queries — the patterns that decide which indexes are actually needed.
  • Actions — where transactional writes live, including the Create{Entity} Action that consumes the table you just defined.
  • DTOs — the typed payloads that drive inserts and updates.
  • Anti-patterns — the project's authoritative list of violations to grep for in review.