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.
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.
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.
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{ //}
For this case, let's spin up a new Laravel installation with a Breeze Starter kit.
laravel new laravel-unused-filescd 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.
If we update the avatar multiple times, we will notice that we have accumulated several files, but only one is in use.
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.
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');}
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); } } }}
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
.
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:
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
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();