The model named number, issued date and status, and left the team id off the form. A migration stores those columns for the person who runs it in production, including you. Constrain the foreign key in the same migration that adds the column. The staff note is introduced here, as notes, and comes back in the indexes and API lessons.
Eloquent, properly · Lesson 1
Migrations you can still read next year
Video
01 One invoice belongs to one team
foreignId()->constrained() is the readable form. foreignId() adds the unsigned bigint. constrained() adds the foreign key, and it assumes the related table is teams. MySQL creates the index that foreign key needs. Laravel does not add a second index() for the column. cascadeOnDelete() is a decision, not a default: deleting a team should delete its invoices only when the product says so. Leave it off and the database refuses to delete a team that still has invoices. That refusal is often the better letter.
class Example
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('number', 32);
$table->date('issued_on');
$table->string('status', 16)->default('draft');
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['team_id', 'number']);
});
}
}The unique pair is the rule the domain already has: a team does not issue the same invoice number twice. Put that in the database, not only in a validator. Two requests can pass a unique rule in PHP and still insert two rows. The index is the constraint that still holds when the validator is skipped, queued, or wrong.
down() should undo the same change. If up() created the table, down() drops it. A migration that only added a column drops that column. Do not leave down() empty because rolling back feels unlikely. The method is how you read the change backwards.
class Example
{
public function down(): void
{
Schema::dropIfExists('invoices');
}
}Once this migration has run in production, stop editing it. A new column is a new file with a later timestamp. Editing an old up() does nothing on a database that already ran it, and it rewrites history for everyone who has not migrated yet.
02 Add the lines in the next migration
An invoice is not finished until it has lines. The lines table is the next letter, not a quiet edit to the invoices migration. invoice_id uses the same foreignId()->constrained() form, so a line cannot point at an invoice that does not exist. cascadeOnDelete() here matches the product choice from the one-to-many lesson: deleting the invoice deletes its lines. amount is an unsigned integer because the column stores pence, not a float.
Schema::create('invoice_lines', function (Blueprint $table): void {
$table->id();
$table->foreignId('invoice_id')->constrained()->cascadeOnDelete();
$table->string('description');
$table->unsignedInteger('amount');
$table->timestamps();
});down() for this file drops invoice_lines and leaves invoices alone. The two tables were created by two migrations, so they roll back separately. Name the class for the change, AddInvoiceLinesTable or whatever your prefix already uses, and resist a class called UpdateStuff. The filename is the index a tired person searches at ten at night.
If a team deletion must not remove invoices, take cascadeOnDelete() off team_id and let the database raise the foreign key error. Make that change in a new migration that drops the foreign key and adds it again without the cascade. The original file stays as the letter you sent.
Recap
You can add the team foreign key and the unique invoice number in one migration, then add lines in the next.
foreignId()->constrained()names the relationship in the schema.cascadeOnDelete()is a product decision, not a default you copy.unique(['team_id', 'number'])is the rule a validator can forget.- A table that production already has is changed by a new migration, not by editing the old one.
Check yourself · 3 questions
Question 1 of 3Two teams both issue INV-1. What should the database do?