Loading...

This is taking longer than expected.

Coding standards

Laravel Migrations

These are the standards the analyzer applies. Every rule states what it checks and why it exists.

All standards

General Rules

  • Never use ->cascadeOnDelete() in migration foreign key definitions
  • Never use ->nullOnDelete() in migrations
  • Use ->constrained() when appropriate
  • Use our ->dropColumnWithIndexes() function instead of ->dropColumn()

One-Off Backfill Logic Stays in the Migration

A migration runs exactly once in the life of a project, so a bounded backfill only that migration performs belongs inside the migration itself. Do not move that logic to a model or command so the migration can call it: nothing will ever call it again. A backfill that is not bounded does not stay inline either: it follows Heavy Data Work Goes Through the Queue.

Heavy Data Work Goes Through the Queue

A migration changes the schema: tables, columns, indexes. It must return in seconds, because the deploy waits for it, and a migration killed mid-run is retried from the start. Data work that can touch many rows or files does not run inside migrate; it runs on the queue, dispatched from the same migration file.
Heavy data work is anything whose cost grows with the data: a loop over rows that does the work itself, copying, moving or deleting files on a disk or S3, hashing files, or calling an external service. Bounded work stays inline: a fixed key or id list, such as creating a permission or correcting a text, and a single set-based statement (one update(), delete() or insert … select), which is one round trip however many rows it reaches, unless the table is large enough for the statement itself to hold locks for a long time.
The approved way is one file: the migration makes the schema change, selects only the ids of the rows to handle, and dispatches one static function closure per chunk. Do not create a Job class for a migration.

  • Guard the schema part so a repeated run is harmless (Schema::hasColumn()), then walk the ids with chunkById(), selecting only id. Use 20 to 50 ids per chunk for file, S3 or external-service work and a few hundred for plain SQL; the number is whatever lets one chunk finish well under the worker timeout of the target environment.
  • The closure must be static and capture only plain values (use ($table, $ids)). A closure that uses $this, or self:: or static::, cannot run: a migration is an anonymous class, so $this fails to serialize (Serialization of 'class@anonymous' is not allowed) and self:: resolves to a class name the worker does not know. All the logic therefore lives inside the closure, and helper methods of the migration cannot be called from it.
  • The closure travels in the queue with its own code and the class names the migration imports, so it still runs after the migration file is deleted. Every class it uses must still exist when it runs; when a later change could drop a column or class it reads, use DB::table() instead of a model.
  • Make each closure idempotent: re-check what is already done before acting, never duplicate a row or a file, and delete an original only after its copy is verified. It can then be retried, cut or run twice with the same result. Log progress so a stuck migration can be told apart from a slow one. Do not resume from a stored last-id cursor, and do not make a closure dispatch itself again: the migration already queued every chunk.
  • The dispatch must not starve the rest of the queue: spread the chunks with delay() or chain them instead of queueing thousands at once.
  • Dispatch on a real queue connection: with QUEUE_CONNECTION=sync the closures run inline inside migrate. Add ->afterCommit() so, where the migration runs in a transaction, no closure starts before the schema change is visible.
  • The deploy does not wait for this work, and the data is not migrated when the deploy ends. A queue worker must be running: php artisan queue:restart only tells running workers to reload, it does not start one.
  • Such a migration may be deleted once every environment has applied it and the jobs table holds none of its closures.

The analyzer flags, inside a migration, row loops that do the work themselves, Storage:: and ->disk() calls, file hashes, Http:: and curl calls outside a dispatched closure, and a dispatched closure that is not static or uses $this, self:: or static::. It does not detect every kind of heavy work (an each over a query, for one), so a clean run does not prove none remains.

// Correct — the migration changes the schema and dispatches static closures
public function up(): void
{
    if (! Schema::hasColumn('product_files', 'checksum')) {
        Schema::table('product_files', function (Blueprint $table) {
            $table->string('checksum')->nullable();
        });
    }

    DB::table('product_files')->whereNull('checksum')->select('id')->chunkById(30, function ($rows) {
        $ids = $rows->pluck('id')->all();

        dispatch(static function () use ($ids) {
            foreach ($ids as $id) {
                // re-check, copy, verify, then delete the original
            }
        })->afterCommit();
    });
}

// Incorrect — the deploy waits for every file
public function up(): void
{
    ProductFile::query()->chunkById(100, function ($files) {
        foreach ($files as $file) {
            Storage::disk('s3')->copy($file->path, $file->media_path);
        }
    });
}

Namespace Changes Update Morph Types

When a model moves to another namespace, a migration must update every stored *_type value (e.g. priceable_type, limitable_type, categorizable_type) to the new class name. Do not keep a morph map or class aliases as a permanent bridge between the old and the new namespace.

Weblabor Logo Developed by Weblabor