skip to content

In a Laravel migration, what does $table->foreignId('seller_id')->constrained()->cascadeOnDelete() create, and why must nullable() come before constrained()?

level: middleimportance: must knowfreq 60%

answer

  1. unsigned big integer column
  2. table guessed: seller_id to sellers
  3. constrained() returns ForeignKeyDefinition
  4. later modifiers hit the constraint
  5. dropConstrainedForeignId in down()

basics

~20 s

It adds an unsigned big-integer seller_id column and a foreign key to sellers.id that deletes listings when their seller is deleted. constrained() returns the foreign-key definition, so a nullable() chained after it never reaches the column.

solid answer

~30 s

`foreignId('seller_id')` adds an `UNSIGNED BIGINT` equivalent column that matches `$table->id()`. `constrained()` adds a foreign key and, with no arguments, guesses the target from the column name: strip `_id`, pluralise, reference `id`, giving `sellers.id`; pass `constrained(table: 'users')` when the convention does not fit. `cascadeOnDelete()` sets `ON DELETE CASCADE`; siblings include `restrictOnDelete()`, `nullOnDelete()` and `cascadeOnUpdate()`. **Column** modifiers must come before `constrained()`, because it returns an `Illuminate\Database\Schema\ForeignKeyDefinition`, not the column. `->constrained()->nullable()` records `nullable` on the constraint definition, where it has no effect, and the column stays `NOT NULL`. The constraint is named `listings_seller_id_foreign`; in `down()`, `$table->dropConstrainedForeignId('seller_id')` drops the key and then the column.

code

php · 26 lines
php
<?php

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::table('listings', function (Blueprint $table) {
            $table->foreignId('seller_id')->constrained()->cascadeOnDelete();
            $table->foreignId('buyer_id')->nullable()->constrained('users')->nullOnDelete();
            $table->index(['seller_id', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::table('listings', function (Blueprint $table) {
            $table->dropIndex(['seller_id', 'created_at']);
            $table->dropConstrainedForeignId('buyer_id');
            $table->dropConstrainedForeignId('seller_id');
        });
    }
};

go deeper

for a junior

Recall that foreignId() adds the column, constrained() adds the foreign key by convention, and cascadeOnDelete() sets the delete action.

for a middle

Explain the table-name convention, why column modifiers precede constrained(), and how index and constraint names are derived and dropped.

for a senior

Review migrations for silent modifier mistakes, reversible down() methods and referential actions that match the domain's delete rules.

for a principal

Decide where the database enforces referential integrity and cascades, versus where the application owns deletes.

## The one-liner, unpacked In a marketplace, every listing belongs to a seller. The migration for that relation is usually one line: ```php Schema::table('listings', function (Blueprint $table) { $table->foreignId('seller_id')->constrained()->cascadeOnDelete(); }); ``` That line does three separate things: 1. **`foreignId('seller_id')`** adds a column of the `UNSIGNED BIGINT` family, the same type `$table->id()` creates for primary keys, so the two sides match. 2. **`constrained()`** creates a **foreign key constraint**. With no arguments it applies Laravel's conventions: take the column name, remove the `_id` suffix, pluralise it, and reference its `id` column. `seller_id` therefore points at `sellers.id`. 3. **`cascadeOnDelete()`** sets the constraint's `ON DELETE` action to `CASCADE`, so deleting a seller deletes their listings in the database. When the convention does not fit, pass arguments: `constrained(table: 'users')` for a `seller_id` that references `users`, or `constrained(table: 'users', indexName: 'listings_seller_fk')` to name the constraint yourself. ## Referential actions | Method | SQL action | |---|---| | `cascadeOnDelete()` | `ON DELETE CASCADE` | | `restrictOnDelete()` | `ON DELETE RESTRICT` | | `nullOnDelete()` | `ON DELETE SET NULL`, needs a nullable column | | `noActionOnDelete()` | `ON DELETE NO ACTION` | | `cascadeOnUpdate()` | `ON UPDATE CASCADE` | `onDelete('cascade')` and `onUpdate('cascade')` are the longer equivalents. ## Why the order of modifiers matters The **column** and the **constraint** are two different objects in Laravel's schema builder: - `foreignId()` returns a `ForeignIdColumnDefinition`, which is a column definition. Column modifiers such as `nullable()`, `default()`, `comment()` or `after()` set attributes on it. - `constrained()` returns an `Illuminate\Database\Schema\ForeignKeyDefinition`, which describes the constraint. Both are **fluent** objects that accept any method name and store it as an attribute. So `->constrained()->nullable()` does not fail: it quietly records `nullable` on the constraint, which the grammar ignores, and the column is created `NOT NULL`. The docs state the rule plainly: additional column modifiers must be called before `constrained()`: ```php $table->foreignId('buyer_id')->nullable()->constrained('users')->nullOnDelete(); ``` This matters most with `nullOnDelete()`, which needs a nullable column: getting the order wrong pairs a `SET NULL` action with a `NOT NULL` column, which some databases reject when the constraint is created and others only when a delete tries to write the `NULL`. ## Indexes and names Laravel names indexes and constraints from the table, the columns and a suffix: - `$table->index(['seller_id', 'created_at'])` becomes `listings_seller_id_created_at_index`. - `$table->unique('slug')` becomes `listings_slug_unique`. - The foreign key above becomes `listings_seller_id_foreign`. Passing the same column array to a drop method rebuilds the name for you: `dropIndex(['seller_id', 'created_at'])`, `dropUnique(['slug'])`, `dropForeign(['seller_id'])`. ## Writing `down()` A foreign key usually has to be dropped before its column. Laravel bundles both: ```php public function down(): void { Schema::table('listings', function (Blueprint $table) { $table->dropConstrainedForeignId('seller_id'); }); } ``` `dropConstrainedForeignId('seller_id')` calls `dropForeign(['seller_id'])` and then `dropColumn('seller_id')`. ## Related shortcuts - **`foreignIdFor(Seller::class)`** derives the column name from a model class, `seller_id` here, and chains `constrained()` the same way. - **`foreignUuid('seller_id')`** and **`foreignUlid('seller_id')`** create the matching column types when the referenced table uses UUID or ULID keys; `constrained()` works on them too. - **Explicit form:** `$table->unsignedBigInteger('seller_id')` followed by `$table->foreign('seller_id')->references('id')->on('sellers')` is what the shortcut expands to, and is still useful when the column already exists. ## SQLite caveat SQLite enforces foreign keys only when they are switched on. Laravel's `sqlite` connection turns them on through the `foreign_key_constraints` option, read from `DB_FOREIGN_KEYS` and `true` in the skeleton, so a cascade that works locally works for the right reason.

  • Your listings table stores the seller in a column named owner_id that references users. How do you declare it?
    The convention would guess an `owners` table, so name the target: `$table->foreignId('owner_id')->constrained(table: 'users')`. Everything else stays the same: the column type matches `users.id`, and the constraint is named `listings_owner_id_foreign` unless you pass `indexName`.
  • Why might dropColumn('seller_id') fail in down() when the foreign key is still present?
    Many databases refuse to drop a column that a foreign key constraint still uses. Drop the constraint first with `dropForeign(['seller_id'])`, then the column, or use `dropConstrainedForeignId('seller_id')`, which does both in that order.

saying these in an interview costs you the question

  • Chains nullable() after constrained() and expects a nullable column
  • Thinks foreignId() alone creates the foreign key constraint
  • Believes constrained() on seller_id references the seller table
  • Uses nullOnDelete() on a column that is not nullable
  • Drops the column in down() before its foreign key