Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Your Old Code Is Still Running

  • software-engineering
  • laravel
  • typescript
  • databases

The migration looked fine. Rename a column, update the model, update the frontend, deploy. Tests are green.

Then the errors start. Not many. Just enough to be annoying, and gone ten minutes later.

That's because a deploy isn't a single moment. For a while, old code and new code are both alive. Old servers still finishing requests. Jobs that were queued an hour ago. A browser tab someone opened this morning that still has yesterday's JavaScript in it.

If your change only works when everything flips at once, it doesn't work.

The window nobody plans for

Most deploys look something like: run migrations, swap in the new code, restart workers.

Between step one and step two, the old code is talking to the new schema. Between step two and "the queue drained," new code is processing jobs the old code created. And the frontend? That's whenever people refresh. Which is, roughly, whenever they feel like it.

None of this is exotic. It's every deploy. Most changes just happen to survive it by accident.

Renaming a column is three deploys

Say users.name should become display_name. The one-step version drops name while old code is still reading it. Instant 500s.

The boring version is expand, migrate, contract.

First, add the new column. Nullable, so existing rows are fine:

Schema::table('users', function (Blueprint $table) {
    $table->string('display_name')->nullable();
});

Ship code that writes both columns:

$user->forceFill([
    'name' => $request->validated('name'),
    'display_name' => $request->validated('name'),
])->save();

Then backfill, switch reads to display_name, and only after that's been live for a while, drop name in a later deploy.

More steps, yes. But every step is safe for whatever code is running on either side of it.

Don't backfill inside the migration

A migration that updates every row in a big table is a migration that holds up your deploy and maybe a lock you didn't think about.

Do it in a job or a command, in chunks:

User::query()
    ->whereNull('display_name')
    ->chunkById(500, function ($users) {
        foreach ($users as $user) {
            $user->update(['display_name' => $user->name]);
        }
    });

chunkById matters here. Plain chunk with offsets skips rows when you're updating the same column you filter on. chunkById pages by primary key, so it doesn't.

And if you're not sure what SQL a migration is about to run, ask:

php artisan migrate --pretend

It prints the queries without running them. Cheap insurance before touching a table with real data in it.

Adding NOT NULL to a table that already has rows

$table->string('locale') with no default on a populated table will either fail or quietly fill every existing row with something you didn't pick, depending on your database and its settings. Neither is a plan.

Add it nullable, or with a default you actually mean. Backfill. Tighten the constraint later with ->change() once every row has a real value.

And on big tables, some schema changes lock writes while they run. Check what your database version does before production checks for you.

Queued jobs are old code too

A job pushed before the deploy gets handled by the code after it. Laravel serializes the job's properties into the payload, so whatever shape the job had when it was queued is the shape that comes back out.

Add a new required constructor argument and every job already sitting in the queue shows up without it.

Give new properties a default, so old payloads still make sense:

class SendInvoice implements ShouldQueue
{
    use Queueable;
 
    // Jobs queued before this deploy won't have it.
    public ?string $locale = null;
 
    public function __construct(public Invoice $invoice, ?string $locale = null)
    {
        $this->locale = $locale;
    }
}

Then handle the null in handle(). For bigger changes, make a new job class and leave the old one around until the queue has drained.

The browser didn't get the memo

Single page apps are the worst offender. Someone loads your app, leaves the tab open, and keeps using that bundle for days. Your API changed this afternoon. Their frontend didn't.

So don't rename fields in an API response. Add the new one next to the old one:

type UserResponse = {
  id: number;
  name: string; // still sent until old clients are gone
  displayName?: string;
};
 
const label = user.displayName ?? user.name;

The new frontend prefers the new field and survives without it. The old frontend never knew it existed and keeps working.

Same thing going the other way. If the request body is changing, accept both for a while:

$name = $request->input('display_name', $request->input('name'));

Delete the fallback once you're confident the old clients are gone. Not on the same day you added it.

The habit

Before you ship a schema, job, or API change, ask: what happens if the old code sees this? And what happens if the new code sees old data?

If either answer is "it breaks," split the change. Add first. Switch. Remove last.

Your old code is still running. Write changes it can live with.

← All posts