Managing database storage becomes critical as your Laravel application grows. Old records can slow down queries, consume valuable disk space, and create maintenance headaches. Learning how to automatically delete records older than 7 days keeps your database lean and your application running smoothly.
This comprehensive guide shows you exactly how to implement automatic record deletion in Laravel using task scheduling, custom commands, and best practices that protect your data. Whether you’re dealing with logs, temporary files, or user-generated content, you’ll master the techniques that Laravel professionals use in production environments.
Introduction to Automatic Record Deletion in Laravel
Why You Might Want to Delete Old Records
Database bloat is one of the silent killers of application performance. As your Laravel app accumulates data over months and years, tables grow exponentially, making queries slower and backups larger. Automatic deletion solves this problem by removing outdated records before they impact your system.
Storage costs money, especially in cloud environments where you pay for every gigabyte. Removing unnecessary data reduces hosting expenses and keeps your database size manageable. More importantly, smaller tables mean faster queries and better user experiences.
Common Use Cases for Time-Based Deletions in Laravel
Log Management: Application logs, error reports, and debug information typically become useless after a week. Automatically purging these records prevents log tables from consuming gigabytes of storage.
Session Cleanup: User sessions, temporary tokens, and cache entries often have built-in expiration dates but remain in the database until manually removed.
Audit Trail Maintenance: While audit logs are important for compliance, keeping years of detailed records may not be necessary. Regular cleanup maintains compliance while managing storage.
User Activity Data: Page views, click tracking, and analytics data can accumulate rapidly. Deleting older activity records keeps your analytics tables performant.
File Upload Cleanup: Temporary uploads, draft content, and abandoned form data create clutter that automatic deletion can address.
Understanding the Risks and Precautions
Automatic deletion is powerful but dangerous if implemented incorrectly. Once data is deleted, recovery becomes difficult or impossible without proper backups. Always test deletion logic thoroughly in development environments before deploying to production.
Consider the business impact of deleted data. Some records might seem old but could be referenced by active features or required for regulatory compliance. Collaborate with stakeholders to define appropriate retention policies before implementing automatic cleanup.
Getting Started with Laravel Task Scheduling
What is Laravel Scheduler and How Does it Work?
Laravel’s task scheduler provides an elegant solution for running recurring commands without managing multiple cron jobs. Instead of creating dozens of cron entries, you define all scheduled tasks within your Laravel application and set up a single cron job that runs every minute.
The scheduler evaluates your defined tasks and executes those due to run. This approach centralizes schedule management within your codebase, making it version-controlled and environment-specific.
Here’s how Laravel scheduling works:
- Define scheduled tasks in
app/Console/Kernel.php
- Set up a single cron job to run
php artisan schedule:run
every minute - Laravel checks which tasks are due and executes them automatically
Setting Up Your Cron Job for schedule:run
First, add the Laravel scheduler to your server’s crontab. Open your crontab editor:
crontab -e
Add this line to run the Laravel scheduler every minute:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
For production environments, consider logging scheduler output:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1
Verify your cron job is working by checking the scheduler output:
php artisan schedule:list
Creating Your First Scheduled Command
Generate a new Artisan command for record deletion:
php artisan make:command DeleteOldRecords
This creates app/Console/Commands/DeleteOldRecords.php
. Here’s the basic structure:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Carbon\Carbon;
class DeleteOldRecords extends Command
{
protected $signature = 'records:delete-old {--days=7 : Number of days to keep}';
protected $description = 'Delete records older than specified days';
public function handle()
{
$days = $this->option('days');
$this->info("Deleting records older than {$days} days...");
// Deletion logic will go here
return Command::SUCCESS;
}
}
The command signature includes an optional --days
parameter, making it flexible for different retention periods.
Writing the Logic to Delete Records Older Than 7 Days
Using Eloquent to Filter and Delete Old Records
Laravel’s Eloquent ORM makes it straightforward to filter and delete old records. Use Carbon for date manipulation and Eloquent’s where
clauses to target specific timeframes.
Here’s a basic example for deleting old log entries:
use App\Models\ActivityLog;
use Carbon\Carbon;
public function handle()
{
$cutoffDate = Carbon::now()->subDays($this->option('days'));
$deletedCount = ActivityLog::where('created_at', '<', $cutoffDate)->delete();
$this->info("Deleted {$deletedCount} activity log records");
return Command::SUCCESS;
}
For more complex scenarios, you might need to delete from multiple tables:
public function handle()
{
$days = $this->option('days');
$cutoffDate = Carbon::now()->subDays($days);
// Delete old logs
$logCount = ActivityLog::where('created_at', '<', $cutoffDate)->delete();
// Delete old sessions
$sessionCount = UserSession::where('last_activity', '<', $cutoffDate->timestamp)->delete();
// Delete old temporary files
$fileCount = TemporaryFile::where('created_at', '<', $cutoffDate)->delete();
$this->info("Cleanup complete:");
$this->info("- Logs: {$logCount}");
$this->info("- Sessions: {$sessionCount}");
$this->info("- Files: {$fileCount}");
return Command::SUCCESS;
}
Creating a Custom Artisan Command for Deletion
Expand your deletion command to handle multiple record types safely:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Carbon\Carbon;
use App\Models\ActivityLog;
use App\Models\UserSession;
use App\Models\TempUpload;
use Illuminate\Support\Facades\Log;
class DeleteOldRecords extends Command
{
protected $signature = 'records:delete-old
{--days=7 : Number of days to keep}
{--model= : Specific model to clean up}
{--dry-run : Show what would be deleted without actually deleting}';
protected $description = 'Delete records older than specified days';
protected $models = [
'activity_logs' => ActivityLog::class,
'user_sessions' => UserSession::class,
'temp_uploads' => TempUpload::class,
];
public function handle()
{
$days = $this->option('days');
$specificModel = $this->option('model');
$dryRun = $this->option('dry-run');
$cutoffDate = Carbon::now()->subDays($days);
$this->info("Cleaning up records older than {$cutoffDate->format('Y-m-d H:i:s')}");
if ($dryRun) {
$this->warn("DRY RUN MODE - No records will be deleted");
}
$totalDeleted = 0;
foreach ($this->models as $name => $modelClass) {
if ($specificModel && $specificModel !== $name) {
continue;
}
$count = $this->cleanupModel($modelClass, $cutoffDate, $dryRun);
$totalDeleted += $count;
$this->info("- {$name}: {$count} records " . ($dryRun ? 'would be deleted' : 'deleted'));
}
Log::info("Cleanup command completed", [
'total_deleted' => $totalDeleted,
'cutoff_date' => $cutoffDate,
'dry_run' => $dryRun
]);
return Command::SUCCESS;
}
protected function cleanupModel($modelClass, $cutoffDate, $dryRun = false)
{
$query = $modelClass::where('created_at', '<', $cutoffDate);
if ($dryRun) {
return $query->count();
}
return $query->delete();
}
}
This enhanced command includes:
- Dry-run mode for testing
- Model-specific cleanup options
- Comprehensive logging
- Safe deletion with confirmation
Testing Your Deletion Logic Safely
Always test deletion commands in development before production deployment. Create test records and verify the logic works correctly:
// Create test records in tinker
php artisan tinker
// Generate old records for testing
ActivityLog::factory()->create(['created_at' => now()->subDays(10)]);
ActivityLog::factory()->create(['created_at' => now()->subDays(5)]);
ActivityLog::factory()->create(['created_at' => now()->subDays(1)]);
// Test with dry-run
php artisan records:delete-old --days=7 --dry-run
// Test actual deletion
php artisan records:delete-old --days=7
For more thorough testing, create a dedicated test:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\ActivityLog;
use Carbon\Carbon;
class DeleteOldRecordsTest extends TestCase
{
public function test_deletes_old_records_correctly()
{
// Create old record (should be deleted)
$oldRecord = ActivityLog::factory()->create([
'created_at' => Carbon::now()->subDays(10)
]);
// Create recent record (should be kept)
$recentRecord = ActivityLog::factory()->create([
'created_at' => Carbon::now()->subDays(3)
]);
$this->artisan('records:delete-old --days=7');
$this->assertDatabaseMissing('activity_logs', ['id' => $oldRecord->id]);
$this->assertDatabaseHas('activity_logs', ['id' => $recentRecord->id]);
}
}
Integrating the Deletion Command with Laravel Scheduler
Registering the Command in Kernel.php
Add your deletion command to Laravel’s task scheduler in app/Console/Kernel.php
:
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected $commands = [
Commands\DeleteOldRecords::class,
];
protected function schedule(Schedule $schedule)
{
// Run cleanup daily at 2 AM
$schedule->command('records:delete-old --days=7')
->dailyAt('02:00')
->withoutOverlapping()
->runInBackground();
// Alternative: Run weekly on Sundays
$schedule->command('records:delete-old --days=7')
->weekly()
->sundays()
->at('02:00');
// Run with different retention for different models
$schedule->command('records:delete-old --days=30 --model=activity_logs')
->dailyAt('03:00');
}
}
Key scheduling options explained:
withoutOverlapping()
: Prevents multiple instances from running simultaneouslyrunInBackground()
: Allows other scheduled tasks to run concurrentlyonFailure()
: Defines actions when the command failsonSuccess()
: Defines actions when the command succeeds
Setting the Right Frequency for Automatic Cleanup
Choose scheduling frequency based on your data volume and business needs:
Daily Cleanup (Recommended for high-traffic apps):
$schedule->command('records:delete-old --days=7')->daily();
Weekly Cleanup (Good for moderate traffic):
$schedule->command('records:delete-old --days=7')->weekly();
Hourly Cleanup (For very high-volume applications):
$schedule->command('records:delete-old --days=7')->hourly();
Custom Frequency:
// Every 6 hours
$schedule->command('records:delete-old --days=7')->cron('0 */6 * * *');
// Only on weekdays at 3 AM
$schedule->command('records:delete-old --days=7')
->weekdays()
->at('03:00');
Logging and Notifications After Deletion Runs
Implement comprehensive logging and notifications to monitor your cleanup operations:
protected function schedule(Schedule $schedule)
{
$schedule->command('records:delete-old --days=7')
->dailyAt('02:00')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/cleanup.log'))
->emailOutputOnFailure('[email protected]')
->onSuccess(function () {
// Log successful cleanup
Log::info('Daily cleanup completed successfully');
})
->onFailure(function () {
// Send alert for failed cleanup
Log::error('Daily cleanup failed');
// Send notification to monitoring service
});
}
For more advanced monitoring, integrate with notification channels:
use Illuminate\Support\Facades\Notification;
use App\Notifications\CleanupCompleted;
$schedule->command('records:delete-old --days=7')
->dailyAt('02:00')
->onSuccess(function () use ($schedule) {
Notification::route('mail', '[email protected]')
->route('slack', 'your-slack-webhook-url')
->notify(new CleanupCompleted());
});
Example of Pruneable Trait Implementation
Laravel 8.37+ introduced the Pruneable
trait, which provides an elegant way to automatically delete old model records. This approach is cleaner and more Laravel-like than custom commands for simple use cases.
Setting Up Pruneable Models
First, add the Pruneable
trait to your model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Carbon\Carbon;
class ActivityLog extends Model
{
use Prunable;
protected $fillable = ['action', 'user_id', 'ip_address', 'user_agent'];
/**
* Get the prunable model query.
*/
public function prunable()
{
return static::where('created_at', '<=', Carbon::now()->subDays(7));
}
/**
* Prepare the model for pruning (optional).
*/
protected function pruning()
{
// Perform any cleanup before the model is deleted
// For example, delete related files
if ($this->file_path) {
Storage::delete($this->file_path);
}
}
}
Scheduling Prunable Models
Add the pruning command to your scheduler:
protected function schedule(Schedule $schedule)
{
// Prune all models that use the Prunable trait
$schedule->command('model:prune')->daily();
// Prune specific models only
$schedule->command('model:prune --model="App\Models\ActivityLog"')->daily();
// Prune with chunking for large datasets
$schedule->command('model:prune --chunk=1000')->daily();
}
Advanced Pruneable Configuration
For more complex pruning logic, you can customize the prunable query:
class UserSession extends Model
{
use Prunable;
public function prunable()
{
// Delete sessions older than 30 days OR inactive for 7 days
return static::where(function ($query) {
$query->where('created_at', '<=', Carbon::now()->subDays(30))
->orWhere('last_activity', '<=', Carbon::now()->subDays(7));
});
}
protected function pruning()
{
// Log session cleanup
Log::info('Pruning user session', ['session_id' => $this->id]);
// Update related user statistics
$this->user->decrement('active_sessions_count');
}
}
Mass Assignment with Pruneable
For models that need conditional pruning based on relationships:
class TempUpload extends Model
{
use Prunable;
public function prunable()
{
// Only prune uploads that aren't associated with published content
return static::where('created_at', '<=', Carbon::now()->subDays(7))
->whereDoesntHave('posts', function ($query) {
$query->where('status', 'published');
});
}
protected function pruning()
{
// Delete the actual file before removing the record
if (Storage::exists($this->file_path)) {
Storage::delete($this->file_path);
}
// Clear any cache entries
Cache::forget("upload.{$this->id}");
}
}
Best Practices and Tips for Safe Automation
Using Soft Deletes vs Hard Deletes for Flexibility
Soft deletes provide a safety net by marking records as deleted without actually removing them from the database. This approach allows data recovery and maintains referential integrity.
Enable soft deletes in your model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ActivityLog extends Model
{
use SoftDeletes;
protected $dates = ['deleted_at'];
}
Add the deleted_at
column to your migration:
public function up()
{
Schema::table('activity_logs', function (Blueprint $table) {
$table->softDeletes();
});
}
Modify your cleanup command to use soft deletes:
public function handle()
{
$cutoffDate = Carbon::now()->subDays($this->option('days'));
// Soft delete old records
$deletedCount = ActivityLog::where('created_at', '<', $cutoffDate)->delete();
// Permanently delete very old soft-deleted records
$purgedCount = ActivityLog::onlyTrashed()
->where('deleted_at', '<', Carbon::now()->subDays(30))
->forceDelete();
$this->info("Soft deleted: {$deletedCount}, Permanently deleted: {$purgedCount}");
}
Backing Up Data Before Deleting
Implement automatic backups before running deletion operations:
public function handle()
{
if ($this->shouldBackup()) {
$this->createBackup();
}
// Proceed with deletion
$this->performCleanup();
}
protected function shouldBackup()
{
// Create backups only for production or when explicitly requested
return app()->environment('production') || $this->option('backup');
}
protected function createBackup()
{
$timestamp = now()->format('Y-m-d_H-i-s');
$backupFile = storage_path("backups/cleanup_backup_{$timestamp}.sql");
// Export data before deletion
$this->info("Creating backup: {$backupFile}");
// Example using mysqldump
$command = sprintf(
'mysqldump -u%s -p%s %s activity_logs > %s',
config('database.connections.mysql.username'),
config('database.connections.mysql.password'),
config('database.connections.mysql.database'),
$backupFile
);
exec($command);
}
Adding Authorization and Role Checks for Safety
Protect your deletion commands with proper authorization:
public function handle()
{
// Check if the command is running in a safe environment
if (!$this->isSafeToRun()) {
$this->error('This command cannot run in the current environment');
return Command::FAILURE;
}
// Require confirmation for production
if (app()->environment('production') && !$this->option('force')) {
if (!$this->confirm('Are you sure you want to delete old records in production?')) {
$this->info('Operation cancelled');
return Command::SUCCESS;
}
}
$this->performCleanup();
}
protected function isSafeToRun()
{
// Add your safety checks here
$maintenanceMode = app()->isDownForMaintenance();
$hasAdminPermission = $this->checkAdminPermission();
return !$maintenanceMode && $hasAdminPermission;
}
protected function checkAdminPermission()
{
// Implement your authorization logic
// This could check environment variables, config files, or external services
return config('app.cleanup_enabled', false);
}
Monitoring and Debugging Scheduled Jobs
Laravel provides several ways to monitor scheduled job execution:
Check Job History:
php artisan schedule:list
php artisan schedule:test
Monitor with Horizon (if using Redis):
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
Custom Monitoring Integration:
protected function schedule(Schedule $schedule)
{
$schedule->command('records:delete-old --days=7')
->dailyAt('02:00')
->before(function () {
Log::info('Starting daily cleanup');
// Send start notification to monitoring service
Http::post('https://monitoring-service.com/job/start', [
'job' => 'daily-cleanup',
'timestamp' => now()
]);
})
->after(function () {
Log::info('Daily cleanup completed');
// Send completion notification
Http::post('https://monitoring-service.com/job/complete', [
'job' => 'daily-cleanup',
'timestamp' => now()
]);
});
}
Health Check Endpoints:
Create a health check route to monitor scheduler status:
// routes/web.php
Route::get('/health/scheduler', function () {
$lastRun = Cache::get('last_cleanup_run');
$isHealthy = $lastRun && $lastRun->diffInHours(now()) < 25; // Should run daily
return response()->json([
'status' => $isHealthy ? 'healthy' : 'unhealthy',
'last_run' => $lastRun,
'next_due' => now()->addDay()->startOfDay()->addHours(2)
]);
});
Update your cleanup command to register successful runs:
public function handle()
{
// Perform cleanup
$result = $this->performCleanup();
// Register successful completion
if ($result === Command::SUCCESS) {
Cache::put('last_cleanup_run', now(), now()->addDays(2));
}
return $result;
}
Conclusion and Next Steps
Implementing automatic record deletion in Laravel requires careful planning, thorough testing, and proper monitoring. You’ve learned how to use Laravel’s task scheduler, create custom Artisan commands, implement the Pruneable trait, and follow best practices that protect your data while keeping your database optimized.
The techniques covered in this guide provide a solid foundation for database maintenance automation. Your Laravel applications now have the tools to handle data lifecycle management without manual intervention, reducing storage costs and maintaining optimal performance.
Recap of Key Steps to Automate Deletion in Laravel
- Command Creation: Built custom Artisan commands with flexible parameters and safety features
- Scheduler Integration: Configured Laravel’s task scheduler for automated execution
- Pruneable Implementation: Used Laravel’s modern Pruneable trait for elegant model cleanup
- Safety Measures: Implemented soft deletes, backups, and authorization checks
- Monitoring Setup: Added logging, notifications, and health checks for production reliability
Advanced Ideas: Queue-Based Deletions, Notifications
Queue-Based Cleanup for Large Datasets:
For applications with millions of records, consider using queued jobs to prevent timeouts:
# Create a queued job for cleanup
php artisan make:job CleanupOldRecords
// In the job class
class CleanupOldRecords implements ShouldQueue
{
public function handle()
{
ActivityLog::where('created_at', '<', Carbon::now()->subDays(7))
->chunk(1000, function ($records) {
$records->each->delete();
});
}
}
// Dispatch from your scheduled command
$schedule->call(function () {
CleanupOldRecords::dispatch();
})->daily();
Advanced Notification System:
# Create notification for cleanup reports
php artisan make:notification CleanupReport
class CleanupReport extends Notification
{
public function via($notifiable)
{
return ['mail', 'slack', 'database'];
}
public function toSlack($notifiable)
{
return (new SlackMessage)
->success()
->content('Daily cleanup completed')
->attachment(function ($attachment) {
$attachment->title('Cleanup Statistics')
->fields([
'Records Deleted' => $this->deletedCount,
'Storage Freed' => $this->formatBytes($this->bytesFreed),
'Duration' => $this->duration . ' seconds'
]);
});
}
}
Where to Go From Here: Cleanup Strategies Beyond 7 Days
Database Archiving Strategy:
Instead of deleting old records, consider archiving them to separate tables or databases:
class ArchiveOldRecords extends Command
{
public function handle()
{
DB::transaction(function () {
// Move old records to archive table
DB::statement('INSERT INTO archived_activity_logs SELECT * FROM activity_logs WHERE created_at < ?', [
Carbon::now()->subMonths(6)
]);
// Delete from main table
ActivityLog::where('created_at', '<', Carbon::now()->subMonths(6))->delete();
});
}
}
Conditional Cleanup Based on Storage:
public function handle()
{
$databaseSize = $this->getDatabaseSize();
$threshold = 5 * 1024 * 1024 * 1024; // 5GB
if ($databaseSize > $threshold) {
// Aggressive cleanup
$this->performCleanup(30); // Keep only 30 days
} else {
// Standard cleanup
$this->performCleanup(90); // Keep 90 days
}
}
Integration with External Storage:
// Archive to S3 before deletion
use Illuminate\Support\Facades\Storage;
protected function archiveToS3($records)
{
$data = $records->toJson();
$filename = 'archives/' . now()->format('Y/m/d') . '/activity_logs.json';
Storage::disk('s3')->put($filename, $data);
return $filename;
}
Your Laravel applications now have professional-grade data lifecycle management. As your needs evolve, you can expand these patterns to handle more complex scenarios like cross-database cleanup, compliance-driven retention policies, and integration with data warehousing systems.
The foundation you’ve built today scales from small applications to enterprise systems processing millions of records. Focus on monitoring your cleanup operations and adjusting retention policies based on actual usage patterns and business requirements.