Filament & Laravel: Delete Unused Files if Model is Updated/Deleted

In Filament, while using the File Upload field, attached files are not automatically deleted when you delete the Model record itself. How to fix this? I will show two ways.

In the Filament docs for file upload we have this note:

Please note, it is the responsibility of the developer to delete these files from the disk if they are removed, as Filament is unaware if they are depended on elsewhere. One way to do this automatically is by observing a model event.

It's fair enough, as Filament doesn't "know" about those files, so we need to take care of them ourselves.

In this example, we will have a Task Model with title and attachment fields.

database/migrations/xxxx_create_tasks_table.php:

return new class extends Migration {
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('attachment')->nullable();
$table->timestamps();
});
}
};

Our goal is to delete the physical file from the server if the Task is deleted in Filament adminpanel.

In fact, this tutorial is not even that much about Filament, as we will show two core Laravel methods to handle the non-deleted files. So you can use those methods outside Filament, it's just that Filament has this situation out-of-the-box.


Method 1: Observer

The first method is going to be Eloquent Observer. First, we need to create Observer and register it in EventServiceProvider.

php artisan make:observer TaskObserver --model=Task

app/Providers/EventServiceProvider.php:

use App\Models\Task;
use App\Observers\TaskObserver;
 
class EventServiceProvider extends ServiceProvider
{
// ...
public function boot(): void
{
Task::observe(TaskObserver::class);
}
// ...
}

Next, in the Observer we will need two methods:

  • saved(): will delete the old file on the edit page if it was replaced by a new file
  • deleted(): will delete the file when the Task is deleted

app/Observers/TaskObserver.php:

use App\Models\Task;
use Illuminate\Support\Facades\Storage;
 
class TaskObserver
{
public function saved(Task $task): void
{
if ($task->isDirty('attachment')) {
Storage::disk('public')->delete($task->getOriginal('attachment'));
}
}
 
public function deleted(Task $task): void
{
if (! is_null($task->attachment)) {
Storage::disk('public')->delete($task->attachment);
}
}
}

As you can see, $task->isDirty('attachment') checks if the attachment was replaced by a new different file, in that case we need to delete the older one.


Method 2: Scheduled Artisan Command

If you don't want to delete a specific unused file right away, you can schedule an automatic cleanup of all unused files.

For that, we will create an Artisan command and schedule it for every night:

php artisan make:command DeleteUnusedFiles

First, we need to set $signature so that created command would have a proper command for running it. We'll name it delete:unused-files.

app/Console/Commands.php:

class DeleteUnusedFiles extends Command
{
protected $signature = 'delete:unused-files';
// ...
}

Next, in the handle() method we need to add all the logic of this command.

app/Console/Commands.php:

use App\Models\Task;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
 
class DeleteUnusedFilesCommand extends Command
{
protected $signature = 'delete:unused-files';
 
protected $description = 'Command description';
 
public function handle(): void
{
$tasks = Task::pluck('attachment')->toArray();
 
collect(Storage::disk('public')->allFiles())
->reject(fn (string $file) => $file === '.gitignore')
->reject(fn (string $file) => in_array($file, $tasks))
->each(fn ($file) => Storage::disk('public')->delete($file));
}
}

What we're doing here?

  • First, we get all the tasks attachment values and make an array of it
  • Then, we get all files from Storage into a Collection
  • Then, the first reject() method, we remove the .gitignore file, we don't want it to be removed
  • Then, the second reject() checks if the file is in the $tasks array. If it is, we remove it from the Collection.
  • Then, we have the final Collection and use each() to delete each file.

Now, we just need to add this command to the schedule.

app/Console/Kernel.php:

class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
$schedule->command('delete:unused-files')->daily();
}
}

That's it for this quick short tutorial. Which method would you prefer?


If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.

avatar
Daramola Babatunde Ebenezer

Thanks for the tutoria its a nice on.

avatar

Thank you for these fantastic mini tutorials that make grasping Laravel (and Filament) principles so much easier!

Note that the logic becomes much more complicated when using the multiple() feature of the Filament file upload field. In that case, the file paths will be stored as a JSON collection, and it becomes necessary to walk through each array item.

I've left my code based on your tutorial's first approach here: https://gist.github.com/pekka/c9c9503b1ddd2c4ed6b762711d2e59af

It is not super duper tested, but on manual inspection it can successfully handle all the cases I could think of: deleting all files; deleting only one file; and deleting all files and replacing them with one file with the same name as one of the previous ones.

👍 1
avatar

Hi, I saw your gits, very interesting. Can you cover nullable image, I am stucking there

avatar

First, great tutorial and congratulations for the work. But I have an error when I create a new post with an image.

League\Flysystem\Filesystem::delete(): Argument #1 ($location) must be of type string, null given, called in /home/u872130505/domains/invest.rio/laravel/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemAdapter.php on line 496

And even with the error it creates the post and uploads the image correctly. And when I edit the post and change the image, everything works normally.

How to solve this error issue when I create a post?

avatar

I'm trying with the first method

avatar

Sorry it's impossible to debug the situation without seeing the full code of all the files.

avatar

hello, I'm sorry for not having provided the GitHub link, but I ended up finding out what I was doing wrong, the mistake was my own. When I made the observer, I deleted all the methods and only left the one to save and delete, with that the problem.

But thanks for trying to help me

avatar

What if i want to update the images in the RichEditor in filament?

avatar

Get error League\Flysystem\Filesystem::delete(): Argument #1 ($location) must be of type string, null given, called in /home/valpuia/Credentials/hlathu/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemAdapter.php on line 518

My code:

public function saved(Artist $artist): void
{
    if ($artist->isDirty('image')) {
        Storage::disk('public')->delete($artist->getOriginal('image'));
    }
}

My image field is nullable, so If I didn't provide then this error come on create. Then I change the logic to updated(), it works while creating, but after updating the same error is coming.

I also try to add ! is_null($artist->image) in saved() or updated() but still getting this error

avatar
Daramola Babatunde Ebenezer

you need to put a condition to check if the field is not null then you can perform the operation

avatar

I did, but same error. I updated my OP

avatar
Daramola Babatunde Ebenezer

share what you did

avatar

I am trying like this

if ($artist->isDirty('image') || ! is_null($artist->image)) {
    Storage::disk('public')->delete($artist->getOriginal('image'));
}

also try with &&, but still same error

avatar
Daramola Babatunde Ebenezer

Your code should look like this

if ($artist->isDirty('image') && ! is_null($artist->getOriginal('image'))) {
    Storage::disk('public')->delete($artist->getOriginal('image'));
}

also note if $artist->getOriginal('image') does not contain the full path to your file, you will still get the error.

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 59 courses (1056 lessons, total 42 h 44 min)
  • 78 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials