- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
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 question | The tool | What it keeps |
|---|---|---|
| "Can we undo that delete?" | Soft deletes | The row, marked as deleted |
| "Can we get old records out of the way but still look them up?" | Archiving | The row, moved or flagged out of the everyday path |
| "Who changed what, and when?" | Audit log | A record of every change, separate from the data |
| "Can you forget me?" | Hard delete or anonymisation | Nothing 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
deletingmodel event). - Relationships can go missing. A payment that belongs to a soft-deleted tenant will return
nullfor$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_atcolumn, with a local scope likeContract::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
Prunabletrait plus a scheduledmodel:prunedoes 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?

Be first to comment it...