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()
Loading...
This is taking longer than expected.
These are the standards the analyzer applies. Every rule states what it checks and why it exists.
->cascadeOnDelete() in migration foreign key definitions->nullOnDelete() in migrations->constrained() when appropriate->dropColumnWithIndexes() function instead of ->dropColumn()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.
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.
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.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.DB::table() instead of a model.delay() or chain them instead of queueing thousands at once.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.php artisan queue:restart only tells running workers to reload, it does not start one.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);
}
});
}
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.