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?

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.

Like our articles?

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

Recent Premium Tutorials