Skip to content
Go back

Everything That Went Wrong When I Tried to Delete a Team

My app is multi-tenant — teams share the same database, and users can belong to multiple teams. I had an Artisan command to delete a team, and it mostly worked. Then I asked for a full review and discovered five things that were quietly breaking.

1. exit; killed my database transaction

The delete command wrapped everything in DB::beginTransaction(). If the user declined a confirmation prompt, the code called exit; to stop:

DB::beginTransaction();

try {
    // ... delete goals, ideas, files ...

    if ($confirmationCallback("Do you wish to delete the team?")) {
        $team->delete();
    } else {
        DB::rollBack();
        exit;  // This kills the entire PHP process
    }

    DB::commit();
} catch (Exception $e) {
    DB::rollBack();
}

The problem: exit; kills the entire PHP process immediately. It prevents DB::rollBack() from being fully honoured, and it’s untestable — it would kill the test runner too. The fix was replacing exit; with return;.

2. forceDelete() broke withTrashed()

The original command permanently deleted users with $user->forceDelete(). But the Idea model already had relationships set up to handle soft-deleted users:

public function experimentOwner()
{
    return $this->belongsTo(User::class, 'experiment_owner_id')->withTrashed();
}

That ->withTrashed() means even if a user is soft-deleted, their name and avatar still appear on every idea they owned. But forceDelete() permanently removes the row from the database — withTrashed() can’t find a row that doesn’t exist.

if we do this, and I delete a user, which nullifies submitted_by_id/experiment_owner_id on ideas, then what happens when another team member looks at an idea or campaign? it will have no owner?

The answer was to stop calling forceDelete() and use soft deletes instead — the feature Laravel was already designed around:

Taylor Otwell built SoftDeletes into Laravel specifically for this situation — preserving referential integrity while logically removing records

For GDPR requests where you need to fully remove someone, you anonymise the soft-deleted record (replace the name, clear the email) rather than hard-deleting the row.

3. Spatie’s detach() only removed roles for one team

This was the subtlest bug. When reassigning a user during team deletion, the code called:

$user->roles()->detach();

With Spatie Permission in teams mode, $user->roles() is scoped to the current team. So ->detach() only removes the role assignment for that one team. If the user had roles on other teams, those were left in place — which was fine.

But the opposite was also true: when soft-deleting a user who belonged to only one team, ->detach() was supposed to remove all their roles. It only removed the ones for the current team context, potentially leaving orphaned rows in model_has_roles for other teams.

Before:

$user->roles()->detach();

After:

DB::table('model_has_roles')
    ->where('model_id', $user->id)
    ->where('model_type', User::class)
    ->delete();

And for reassignment, where you only want to remove the role for the team being deleted:

DB::table('model_has_roles')
    ->where('model_id', $user->id)
    ->where('model_type', User::class)
    ->where('team_id', $team->id)
    ->delete();

Raw DB queries bypass Spatie’s team scoping entirely, giving you explicit control over what gets deleted.

4. Pivot tables silently accumulated orphaned rows

After deleting several teams over a year, I ran a cleanup and found the database was full of orphaned data:

CategoryCount
Orphaned model_has_roles rows84
Un-deleted ideas on deleted teams107
Un-deleted goals on deleted teams6
Zombie users (only team was deleted)7

The root cause: soft deleting a team doesn’t cascade to its related data. Ideas, goals, and users stay active in the database even though their team is gone. And pivot tables like model_has_roles don’t have a deleted_at column — there’s no concept of soft-deleting a relationship. They just quietly accumulate stale rows.

except model_has_roles and pivots, which don’t have deleted_at columns — explain this?

Pivot tables are join tables — they store a relationship between two things (e.g. user 51 has role 2 on team 10). The row is the relationship. Once the relationship is gone, there’s nothing meaningful to keep. That’s standard Laravel convention — pivot tables use hard deletes because there’s no deleted_at column to set.

The fix was a one-time cleanup migration using raw DB queries:

// Soft-delete ideas on soft-deleted teams
DB::table('ideas')
    ->whereIn('team_id', $softDeletedTeamIds)
    ->whereNull('deleted_at')
    ->update(['deleted_at' => $now]);

// Hard-delete orphaned model_has_roles rows
DB::table('model_has_roles')
    ->whereIn('team_id', $softDeletedTeamIds)
    ->delete();

5. Foreign key ordering matters for permanent deletion

After soft-deleting teams, I wanted a way to permanently remove them once I was sure the data wasn’t needed. But you can’t just forceDelete() a team — foreign key constraints will block you.

The solution was a two-phase team:purge command. Phase 1 cleans up linking tables that would cause FK errors. Phase 2 deletes the main records:

// Phase 1: Clean FK blockers
DB::table('idea_versions')->whereIn('master_id', $ideaIds)->delete();
DB::table('goal_user')->whereIn('goal_id', $goalIds)->delete();
DB::table('model_has_roles')
    ->where('model_type', User::class)
    ->whereIn('model_id', $userIds)
    ->delete();

// Phase 2: Delete main records (order matters)
$team->ideas()->withTrashed()->forceDelete();
$team->goals()->withTrashed()->forceDelete();
$team->users()->withTrashed()->each->forceDelete();
$team->forceDelete();

The command also guards against accidental use — it only works on already-soft-deleted teams with no active users:

$team = Team::onlyTrashed()->find($teamId);

if (! $team) {
    $this->error('Team not found. Only soft-deleted teams can be purged.');
    $this->info('Use team:delete to soft-delete a team first.');
    return;
}

What I ended up with

Three Artisan commands that work together:

  1. user:delete — soft-deletes a user, cleans up roles/permissions/pivots, warns about multi-team membership
  2. team:delete — blocks if non-admin users exist (run user:delete first), then soft-deletes the team and related data
  3. team:purge — permanently removes an already-soft-deleted team when you’re sure the data isn’t needed

The lesson: deletion in a multi-tenant app isn’t one operation. It’s a chain of operations where the order matters, pivot tables need explicit cleanup, and soft deletes are almost always the right first step.


Back to top ↑