Migrations
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 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.
index(), unique(), foreign(), constrained(), dropIndex(), dropUnique(). If you find yourself reaching for the second argument of any of them, stop.What to read next
- 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.
Queries
How to write Eloquent queries in this codebase — filter scopes, the project's query macros, eager loading, and chunking large result sets.
Refactoring Legacy
A five-step playbook for migrating legacy `app/` code into the DDD layout — when it's worth doing, how to keep callers working during the move, and what to leave alone.