Laravel: Delete Old Unused Files - When and How? [Multiple Examples]

Laravel: Delete Old Unused Files - When and How? [Multiple Examples]
Admin
Friday, November 10, 2023 6 mins to read
Share
Laravel: Delete Old Unused Files - When and How? [Multiple Examples]

File upload in Laravel is pretty straightforward, but deleting obsolete files is often overlooked. When you update Eloquent models or change data, the files stay on the server. How to clean them up? We will discuss several ways.

There are several cases of how and when to delete them. However, this may apply not only to files but to relationships too. So, let's dig in.


Case 1: Delete file when deleting Model

Usually, when you delete a Model file, associations are deleted alongside, but files on the filesystem are left orphan. The following options are Filament-compatible.

Option 1: Deleted Listener on Model

Eloquent models dispatch several Events, allowing you to hook into the following moments in a model's lifecycle: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, trashed, forceDeleting, forceDeleted, restoring, restored, and replicating.

Here, we have two hooks to consider: deleting and deleted. The deleting event fires before Model is deleted. That may look sufficient at first glance, but if the database fails (and such things happen), the model will be left present without a file, and this result is not optimal.

Conversely, the deleted event fires after Model is deleted. The same logic applies to all -ing -ed endings.

One way to do that is to define the booted() function with deleted Listener your models.

Given that we store the path to document in the Customer Model document property, such a function would look like this.

app/Models/Customer.php

namespace App\Models;
 
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
 
class Customer extends Model
{
use HasFactory;
 
protected $guarded = [];
 
public static function booted(): void
{
self::deleted(function (self $model) {
if ($model->document !== null) {
Storage::disk('public')->delete($model->document);
}
});
}
}

If document has a value present file, we instruct to delete a file from the public disk using the Storage facade.

This method offers us a quick implementation, but if you have many more things going on, it may be better to implement that using Observer so as not to overcrowd the Model.

Option 2: Model Observer with deleted method

First, we need to create an Observer using the Artisan command.

php artisan make:observer CustomerObserver

Now, in CustomerObserver, we can hook into a deleted event by defining a method with the same name.

app/Observers/CustomerObserver.php

namespace App\Observers;
 
use App\Models\Customer;
use Illuminate\Support\Facades\Storage;
 
class CustomerObserver
{
public function deleted(Customer $customer): void
{
if ($customer->document !== null) {
Storage::disk('public')->delete($customer->document);
}
}
}

To "enable" defined observer, we need to register it in the Model by using the ObservedBy attribute.

app/Models/Customer.php

use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use App\Observers\CustomerObserver;
 
#[ObservedBy([CustomerObserver::class])] // [tl! ++
class Customer extends Model
{
//
}

Case 2: Automatically delete old files when updating the model

For this case, let's spin up a new Laravel installation with a Breeze Starter kit.

laravel new laravel-unused-files
cd laravel-unused-files/
 
php artisan storage:link
 
composer require laravel/breeze
 
php artisan breeze:install blade

Add the avatar column to the users table by updating migration.

database/migrations/2014_10_12_000000_create_users_table.php

Schema::create('users', function (Blueprint $table) {
// ...
$table->rememberToken();
$table->timestamps();
$table->string('avatar')->nullable();
});

Update the User Model by adding the avatar column to the $fillable array.

app/Models/User.php

// ...
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'avatar',
];
 
// ...

Do not forget to set up database credentials in the .env file and migrate the database.

php artisan migrate:fresh

Then, create a new ArticleController and define the route.

app/Http/Controllers/AvatarController.php

namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
 
class AvatarController extends Controller
{
public function update(Request $request)
{
if ($request->hasFile('avatar')) {
$path = Storage::disk('public')
->put('avatars', $request->file('avatar'));
$request->user()->update(['avatar' => $path]);
}
 
return to_route('dashboard');
}
}

routes/web.php

use App\Http\Controllers\AvatarController;
 
// ...
 
Route::post('/avatar', [AvatarController::class, 'update'])->name('avatar');

Finally, we can create a simple form to upload files on the existing dashboard page.

resources/views/dashboard.blade.php

<div class="p-6 text-gray-900">
{{ __("You're logged in!") }}
<img src="{{ '/storage/' . auth()->user()->avatar }}" alt="avatar" class="rounded-full aspect-square w-16 object-cover">
<form action="{{ route('avatar') }}" method="post" enctype="multipart/form-data">
@csrf
<div class="flex flex-col gap-2">
<input type="file" name="avatar">
<button type="submit" class="w-fit bg-gray-600 text-white p-2">
Submit
</button>
</div>
</form>
</div>

This setup will serve as a baseline for the listed options for deleting old files when updating the Model.

Avatar Form

Option 1: Deleting old files in Controller

If we update the avatar multiple times, we will notice that we have accumulated several files, but only one is in use.

Multiple Files

To resolve that issue, we can save the path of the previous file and delete it after the Model is updated.

app/Http/Controllers/AvatarController.php

public function update(Request $request)
{
if ($request->hasFile('avatar')) {
$path = Storage::disk('public')
->put('avatars', $request->file('avatar'));
 
$oldPath = $request->user()->avatar;
 
$request->user()->update(['avatar' => $path]);
 
Storage::disk('public')->delete($oldPath);
}
 
return to_route('dashboard');
}

After each update, only the newest file will be left in storage.

Option 2: Deleting old files using Job

Keeping the user waiting for a response is unnecessary until all files get deleted. That is especially true with a lot of files with larger sizes. In this scenario, we will create a new Job that will be dispatched into Queue.

We have queues running on redis.

.env

QUEUE_CONNECTION=redis

Queue Listener can be launched with the Artisan command.

php artisan queue:listen

If you're new to queues, we've got you covered. Take a Practical Laravel Queues on Live Server course.

Now, let's create a new Job file.

php artisan make:job DeletePublicFiles

app/Jobs/DeletePublicFiles.php

namespace App\Jobs;
 
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
 
class DeletePublicFiles implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
/**
* Create a new job instance.
*/
public function __construct(public array $files = [])
{
}
 
/**
* Execute the job.
*/
public function handle(): void
{
foreach ($this->files as $path) {
if ($path !== null) {
Storage::disk('public')->delete($path);
}
}
}
}

Then, instead of deleting files in the Controller, we dispatch a new DeletePublicFiles Job and pass an array of files to delete. There may be better approaches to deleting a single avatar, but it definitely will help you in the long run when dealing with more files.

app/Http/Controllers/AvatarController.php

public function update(Request $request)
{
if ($request->hasFile('avatar')) {
$path = Storage::disk('public')
->put('avatars', $request->file('avatar'));
 
$oldPath = $request->user()->avatar;
 
$request->user()->update(['avatar' => $path]);
 
dispatch(new DeletePublicFiles([$oldPath]));
}
 
return to_route('dashboard');
}

Option 3: Deleting old files using Observer (works with Filament)

Using this technique, you can keep your Controller only responsible for saving files. If you use Filament, it already does for you, but it won't handle deleting old files if you update them in the admin panel.

Let's start by creating a new Observer.

php artisan make:observer UserObserver

Instead of checking if the request has a file, here we check if the avatar column containing the file path has changed using the isDirty() method. It tells if the Model's attribute has changed since it was fetched from the database. The getOriginal() function gives us an older path value prior to the change, allowing us to know which file to delete.

app/Observers/UserObserver.php

namespace App\Observers;
 
use App\Models\User;
use Illuminate\Support\Facades\Storage;
 
class UserObserver
{
public function updated(User $user): void
{
if ($user->isDirty('avatar')) {
$oldPath = $user->getOriginal('avatar');
if ($oldPath !== null) {
Storage::disk('public')->delete($oldPath);
}
}
}
}

Option 4: Overwriting old file

This option is suitable if you have one file at a time, like avatar, and allow only a single type of extension like JPG. Then, instead of using the put() method, we can use the putFileAs() method and specify a static file name that will be overwritten when we update the avatar.

app/Http/Controllers/AvatarController.php

public function update(Request $request)
{
if ($request->hasFile('avatar')) {
$path = Storage::disk('public')
->putFileAs('avatars/' . auth()->id(), $request->file('avatar'), 'avatar.jpg');
 
$request->user()->update(['avatar' => $path]);
}
 
return to_route('dashboard');
}

We also updated the folder path to 'avatars/' . auth()->id() to "isolate" static files in separate folders by user id. That is a typical pattern when dealing with files and allows you to quickly acquire a user files list by knowing only the user ID without any complex iterations and filename matching against the database if, as opposed, you store everything in a single folder.

Note that we need to update the avatar property for Model even if the path stays the same because it might be null.


Case 3: Deleting old files using Artisan Command combined with Scheduler

The methods mentioned above above work well. But there might be cases where you would like to have a command to delete obsolete files, and there are a few reasons for that:

  • Automatic deletion was implemented later, and you still might have orphaned files;
  • Maybe you have a lot of operations, it costs you performance, and you want to consolidate such tasks;
  • You have sufficient disk space and do not need to delete them immediately;
  • Maybe you would like to do something else with them. đź‘€

Let's create a new Artisan command.

php artisan make:command FileCleanup

Here, we iterate over $allFiles in the filesystem and check if they exist in the database - variable $filesInUse. If $file is absent, we fetch the filesize on disk, increment the $totalSize and $totalFiles counters to have a summary at the end of the execution, and delete the file.

app/Console/Commands/FileCleanup.php

namespace App\Console\Commands;
 
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
 
class FileCleanup extends Command
{
protected $signature = 'app:file-cleanup';
 
protected $description = 'Cleanup files';
 
public function handle()
{
$disk = Storage::disk('public');
$allFiles = $disk->allFiles('avatars');
$filesInUse = User::pluck('avatar')->toArray();
$totalSize = 0;
$totalFiles = 0;
 
foreach ($allFiles as $file) {
if (in_array($file, $filesInUse)) {
continue;
}
 
$totalSize += $disk->size($file);
$totalFiles++;
$disk->delete($file);
}
 
$this->newLine();
$this->line(' <fg=green>Total amount of disk space you gained</> <fg=yellow>' . $this->humanReadableSize($totalSize) . '</>');
$this->line(' <fg=green>Total files deleted</> <fg=yellow>' . $totalFiles . '</>');
$this->newLine();
}
 
protected function humanReadableSize(float $sizeInBytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
 
if ($sizeInBytes === 0.0) {
return '0 ' . $units[1];
}
for ($i = 0; $sizeInBytes > 1024; $i++) {
$sizeInBytes /= 1024;
}
 
return round($sizeInBytes, 2) . ' ' . $units[$i];
}
}

Now, you can see the command in action by running it.

php artisan app:file-cleanup

Total Amount Space Gained Font "Terminus" if asking for a friend.

Optionally, you can schedule it daily by adding a schedule to routes/console.php. The daily() method runs the command at 0:00 (night).

routes/console.php

use Illuminate\Support\Facades\Schedule;
 
Schedule::command('app:file-cleanup')->daily();