Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Soft Deletes, Archives and Audit Logs: Keeping History the Right Way

About Post

Someone deletes a tenant record by mistake. Ten minutes later they ask, very politely, whether you can bring it back. Another day, finance asks who changed a rent amount last March, and what it was before. A month later, a user asks for their personal data to be erased completely.

Three requests about "history", and they need three different tools. The common mistake is to reach for one tool for all of them, usually soft deletes, and then wonder why the database is full of ghosts and still can't answer "who changed this?".

Three questions, three tools

The questionThe toolWhat it keeps
"Can we undo that delete?"Soft deletesThe row, marked as deleted
"Can we get old records out of the way but still look them up?"ArchivingThe row, moved or flagged out of the everyday path
"Who changed what, and when?"Audit logA record of every change, separate from the data
"Can you forget me?"Hard delete or anonymisationNothing personal

Keep those four apart in your head and most of the design decisions make themselves.

Soft deletes: an undo button, not a history

Laravel makes soft deletes a two-line change: a deleted_at column and a trait.

// Migration
$table->softDeletes();

// Model
use Illuminate\Database\Eloquent\SoftDeletes;

class Tenant extends Model
{
    use SoftDeletes;
}

Now $tenant->delete() sets deleted_at instead of removing the row, every Eloquent query quietly adds WHERE deleted_at IS NULL, and $tenant->restore() brings it back. withTrashed() and onlyTrashed() let you see the deleted ones on purpose.

It's great for the "oops" case. But it comes with sharp edges people only find later:

  • The scope only applies to Eloquent. Raw queries, DB::table(), reports and database views will happily include deleted rows unless you filter them yourself.
  • Soft deletes don't cascade. Delete a tenant and their contracts are still "live". Decide what should happen to children, and do it explicitly (for example in a deleting model event).
  • Relationships can go missing. A payment that belongs to a soft-deleted tenant will return null for $payment->tenant. For historical screens, define the relation with ->withTrashed().
  • It doesn't tell you who deleted it, or what it looked like before the last edit. That's the audit log's job.

The unique index trap

This one bites almost every team. Users have a unique email. A user is soft-deleted. Later, the same person signs up again, and the insert fails: the old row still holds the email.

The obvious fix, a unique index on (email, deleted_at), doesn't work in MySQL. Unique indexes allow multiple NULLs, so two active users with the same email would both have deleted_at = NULL and both be allowed. You've just removed the protection you wanted.

A clean MySQL fix is a generated column that only holds the email while the row is active:

Schema::table('users', function (Blueprint $table) {
    $table->dropUnique(['email']);

    $table->string('active_email')
        ->nullable()
        ->storedAs('IF(deleted_at IS NULL, email, NULL)')
        ->unique();
});

Active rows get their email in active_email and must be unique; deleted rows get NULL and never clash. In PostgreSQL you'd use a partial index instead: CREATE UNIQUE INDEX users_active_email ON users (email) WHERE deleted_at IS NULL. Remember to match it in validation: Rule::unique('users')->whereNull('deleted_at').

Archives: out of the way, not out of reach

Archiving is different from deleting. An ended tenancy contract wasn't a mistake. It's finished, and it should stop cluttering everyday screens and slowing everyday queries, while staying available for lookups and reports.

Two common approaches:

  • A status or archived_at column, with a local scope like Contract::active(). Simple, and usually enough.
  • A separate archive table (or database) that old rows are moved to on a schedule. Worth it when the main table gets so large that indexes and backups suffer.

Don't use soft deletes for this. "Deleted" and "finished" mean different things, and mixing them makes questions like "show me all contracts that ended last year" needlessly confusing.

Audit logs: the real history

An audit log answers "who changed what, when, and from what to what". It lives in its own table, gets appended to and never edited. A simplified version with a model observer:

class AuditObserver
{
    public function updated(Model $model): void
    {
        $changes = Arr::except($model->getChanges(), ['updated_at']);

        AuditLog::create([
            'user_id'        => auth()->id(),
            'auditable_type' => $model->getMorphClass(),
            'auditable_id'   => $model->getKey(),
            'event'          => 'updated',
            'old_values'     => array_intersect_key($model->getOriginal(), $changes),
            'new_values'     => $changes,
        ]);
    }
}

Register it on the models that matter (rent amounts, contract terms, roles and permissions), cast the two value columns to arrays, and add created and deleted handlers the same way. If you'd rather not build it, packages like spatie/laravel-activitylog and owen-it/laravel-auditing do this well.

Two gotchas. Model events don't fire for mass updates like Contract::where('status', 'active')->update(['status' => 'ended']), so those changes skip the audit log unless you log them yourself. And never write passwords, tokens or full card numbers into audit values; exclude sensitive fields explicitly.

The rule of thumb: soft deletes are for undo, archives are for "finished", audit logs are for "who did what". If you need to answer questions about the past, you need an audit log, whatever else you use.

When to hard delete

Keeping everything forever is not a virtue. Privacy laws like the GDPR give people a right to have their personal data erased, and data you don't hold can't leak. Soft-deleted rows are still personal data sitting in your database and in every backup.

  • Purge soft-deleted rows after a grace period. Laravel's Prunable trait plus a scheduled model:prune does it for you; on a soft-deleting model, pruning force-deletes.
  • Anonymise when you must keep the record. A payment has to stay for accounting, but it doesn't need the payer's phone number forever. Replace personal fields, keep the financial facts.
  • Treat the audit log as personal data too. It often contains old names and emails. Give it a retention policy like everything else.
class Lead extends Model
{
    use SoftDeletes, Prunable;

    public function prunable(): Builder
    {
        return static::onlyTrashed()->where('deleted_at', '<=', now()->subDays(90));
    }
}

The 90 days is just an example. The real retention period is a business and legal decision, so ask, and write the answer down.

A quick design checklist

  • Soft deletes only where "undo" is a real need.
  • Unique indexes that account for deleted rows.
  • Historical relations defined withTrashed().
  • Archiving with a status or separate table, not with deleted_at.
  • An append-only audit log on the models that carry money or permissions.
  • A pruning or anonymisation schedule, so "deleted" eventually means deleted.

Which of these does your current project have, and which one do you wish it had from day one?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close