Skip to main content
Laravel, shipping fast.

A backfill is a migration's cousin: it changes existing rows to match a new expectation, but it usually ships as its own artisan command instead of a migration file, because it needs a dry run and the ability to stop and resume without leaving data half-migrated.

// app/Console/Commands/ServerMetadataBackfill.php
#[Signature('server:backfill-metadata {--dry-run : Show changes without saving}')]
#[Description('Backfill Server.metadata: drop plan_id, set provider/dedicated/booked. Idempotent.')]
final class ServerMetadataBackfill extends Command
{
    public function handle(): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $changed = 0;
        $skipped = 0;

        Server::query()->chunkById(100, function ($servers) use (&$changed, &$skipped, $dryRun): void {
            foreach ($servers as $server) {
                $metadata = (array) $server->metadata;
                $original = $metadata;

                // ... derive the new shape from the old one

                if ($metadata === $original) {
                    $skipped++;

                    continue;
                }

                if (! $dryRun) {
                    $server->metadata = $metadata;
                    $server->save();
                }

                $changed++;
            }
        });

        return self::SUCCESS;
    }
}

Three things make this safe to run against production, and none of them are optional:

  • --dry-run: shows exactly what would change without writing anything, so you can eyeball the output before you trust it.
  • chunkById(): walks the table in ordered pages instead of loading every row into memory, so a table with a million rows and one with a hundred behave the same way.
  • Skip-if-unchanged: comparing $metadata === $original before writing means running the command a second time does nothing to rows already migrated. Kill the process halfway through, run it again tomorrow, and it picks up exactly where it left off.

A backfill you can only run once is a backfill you're afraid of. One you can interrupt and run again is one you can actually schedule during business hours.

Deletion requests and what they touch

"Delete my account" sounds like one DELETE statement. It never is. A single account touches sites, subscriptions, devices, and other people's ownership of shared resources, and a deletion request has to reason about all of them before it reasons about the account row itself.

Delete request arrives
Block if this user pays for an active subscription
Partition owned sites: sole-owned vs shared vs member-only
Block if a sole-owned site has its own active subscription
Transaction: dispatch teardown for sole-owned sites,
              force-delete known devices,
              soft-delete the user

Here's the shape of that logic, trimmed to the parts that matter:

// app/Livewire/Settings/DeleteUserForm.php
public function deleteUser(Logout $logout): void
{
    $user = Auth::user();

    $payerSiteIds = $user->subscriptions()->active()->pluck('type')->all();
    if (! empty($payerSiteIds)) {
        // ... reject, ask them to cancel via billing first
        return;
    }

    $sitesToDelete = collect();
    foreach ($user->sites as $site) {
        $isOwner = ($site->pivot->role ?? '') === 'owner';
        if (! $isOwner) {
            continue;
        }

        $hasAnotherOwner = DB::table('users_sites')
            ->where('site_id', $site->id)
            ->where('user_id', '!=', $user->id)
            ->where('role', 'owner')
            ->exists();

        if (! $hasAnotherOwner) {
            // A sole-owned site can still have its own active subscription,
            // independent of who pays for it. Don't tear that down silently.
            if ($site->activeSubscription()) {
                // ... reject, ask them to cancel this site's subscription first
                return;
            }

            $sitesToDelete->push($site);
        }
    }

    DB::transaction(function () use ($logout, $user, $sitesToDelete) {
        foreach ($sitesToDelete as $site) {
            DeleteSiteJob::dispatch(
                siteId: (string) $site->id,
                apiKey: $site->server->workspace->token,
            )->afterCommit();
        }

        $user->knownDevices()->delete();

        tap($user, $logout(...))->delete();
    });
}

Billing first: you can't honor a deletion request that would silently orphan an active subscription. That check runs before anything else touches the database.

Ownership partitioning: a "delete my account" request is not the same as "delete every site I've ever touched." A site with another owner, or where this user was just a collaborator, loses the relationship, not the site. Only sites this user solely owns get torn down.

Per-site subscription block: ownership isn't the only thing that can veto a teardown. A sole-owned site can carry its own active subscription independent of who pays for it, and that gets checked and blocked too, the same defense as the billing check up front, just scoped to one site instead of the whole account.

One transaction, mixed durability: the teardown jobs are dispatched inside the transaction with afterCommit(), so they only fire once the transaction has actually committed. The known-devices delete is hard and permanent, because a device record has no meaning once its owner can't authenticate. The user row itself is soft-deleted on purpose, not because the team forgot forceDelete() exists, but because the lead still has business value after the account is gone.

"Delete the user" doesn't mean the same operation on every table it touches. It means permanently gone for the things whose only reason to exist was this specific identity, retained for the things your business still has a legitimate use for. A deletion request that treats every table the same way is either violating a real business need or violating the user's actual request. You don't get to pick which one by accident.