A migration that runs instantly on your laptop can hold up a production table for minutes. The difference is row count and traffic.
Add, don’t change. A new nullable column is safe. Renaming or dropping one breaks the code that is still running during the deploy. Chapter 20 comes back to why.
Make it safe to run twice. A deploy that fails halfway leaves migrations half-applied. Guard the ones that might be re-run:
if (! Schema::hasColumn('consumers', 'rate_limit')) {
Schema::table('consumers', function (Blueprint $table) {
$table->unsignedInteger('rate_limit')->nullable();
});
}
Never rewrite data inside a schema migration. Changing the structure is one deploy. Filling it in is a separate, restartable job, and it is the next section.
When you do walk a table that is taking writes, how you page through it matters:
// Bad: offset paging. Rows shift between pages as other
// requests write, and some are skipped or seen twice.
Consumer::query()->chunk(500, $callback);
// Good: pages by primary key. A row can't move.
Consumer::query()->chunkById(500, $callback);
chunk() uses LIMIT and OFFSET. The moment the loop updates the column it filters on, or another request inserts a row, the pages no longer line up. chunkById() remembers the last ID it saw and asks for the rows after it. Use it, or its lazy twin lazyById(), whenever you modify rows while iterating. (Laravel documentation: Eloquent: Getting Started › Chunking Results.)
Backfills You Can Restart
A backfill changes existing rows to match a new expectation. It ships as its own Artisan command, because it needs a dry run and the ability to stop and resume without leaving the data half-migrated.
// app/Console/Commands/BackfillConsumerSettings.php
#[Signature('consumers:backfill-settings {--dry-run}')]
#[Description('Reshape consumer settings in place')]
class BackfillConsumerSettings extends Command
{
public function handle(): int
{
Consumer::query()->chunkById(
100,
$this->backfill(...),
);
return self::SUCCESS;
}
// ... backfill() and reshape() follow
}
private function backfill(Collection $consumers): void
{
foreach ($consumers as $consumer) {
$new = $this->reshape($consumer->settings);
if ($new === $consumer->settings) {
continue;
}
$this->line("Consumer {$consumer->id} changes");
if (! $this->option('dry-run')) {
$consumer->forceFill(['settings' => $new])
->save();
}
}
}
reshape() is the pure function that turns the old shape into the new one.
Two details in that loop are easy to get wrong. settings is not fillable, so update() would discard the change. In production it would do so without a word, and the command would print “changes” while changing nothing. Chapter 9’s shouldBeStrict() makes the same mistake an exception everywhere else, which is how you hear about it first. forceFill() is the honest way to write a guarded attribute from code you control. And when the shape of settings changes, providerToken() in Chapter 4 has to change in the same deploy. It is the only reader of that key, which is why this is a one-line change, and it throws when the key is missing, which is why a mistake here is an error and not a switch of accounts.
Three things make this safe to run against production, and none is optional.
--dry-runshows what would change without writing, so you can read the output before you trust it.chunkById()walks the table in ordered pages, so a table with a million rows behaves like one with a hundred.- Skip if unchanged. Comparing the new shape with the old one before writing means a second run does nothing to rows already migrated. Kill the process halfway, run it again tomorrow, and it carries on from where it stopped.
A backfill that can run only once is one you will be afraid to run. One you can interrupt and run again can be scheduled during business hours.