Delete Should Remove The Wallpaper Image File


Wallpapers, wallpapers everywhere! We can now List, Create, and Update our growing collection of Wallpapers. But inevitably we make a mistake here and there, and we want to delete a Wallpaper too, every now and again.

Well, as it stands, we can delete a Wallpaper entity just fine already.

The last remaining problem we have is that the underlying Wallpaper image file doesn't get deleted in the process.

Fortunately by now we have all the knowledge we need to make our way through this task without too much fuss.

Much like when Creating (prePersist) and Updating (preUpdate), there is a Doctrine event we can listen for and act upon to help us delete: preRemove.

Let's add this into our service definition to get us started:

# /app/config/services.yml

services:

    app.doctrine_event_listener.wallpaper_listener:
        class: AppBundle\Event\Listener\WallpaperListener
        arguments: ['@app.wallpaper_uploader']
        tags:
            - { name: doctrine.event_listener, event: prePersist }
            - { name: doctrine.event_listener, event: preUpdate }
            - { name: doctrine.event_listener, event: preRemove }

We know by now that this means we will need a public function called preRemove on our WallpaperListener:

<?php

// src/AppBundle/Event/Listener/WallpaperUploadListener.php

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileUploader;

class WallpaperUploadListener
{
    private $uploader;

    public function __construct(FileUploader $uploader)
    {
        $this->uploader = $uploader;
    }

    // other stuff removed for brevity

    public function preRemove(LifecycleEventArgs $args)
    {
    }
}

Easy enough, and as the preRemove method gets given a LifecycleEventArgs instance, we don't have any new use statements to worry about, or even any new objects to learn how to work with.

There's two tasks to take care of:

  • Clean up the entity (set the file to null)
  • Remove the file from long term storage (local disk in our case)

Starting with the first, and easiest:

    public function preRemove(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();

        if (false === $entity instanceof Wallpaper) {
            return false;
        }

        $entity->setFile(null);
    }

Even though we're re-using the guard logic - checking if instanceof Wallpaper - both in preRemove, and upload, for me this is not the easiest candidate to recommend for extraction. For now, we will leave the guard logic duplication in place. Feel free to disagree.

Remember that preRemove will be called for every entity that gets removed, not just Wallpaper instances. This is why we need the guard statement.

All we need to do after checking we are working with a Wallpaper instance is to null the file property. The call to remove the entity is going to be handled by EasyAdminBundle, so we won't need to do that ourselves.

Slightly more tricky is deleting the file itself.

We're going to extract the implementation to a separate service. The reasoning for this is that our WallpaperListener need not, and should not know exactly how to delete a file. Whilst in this example our files are on local disk, they may in future be served from Amazon S3, or some other location, and the specifics of this is nothing the WallpaperListener need to concern itself with.

We will create a new service to handle deletion of files:

<?php

// src/AppBundle/Service/LocalFilesystemFileDeleter.php

namespace AppBundle\Service;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\File;

class LocalFilesystemFileDeleter
{
    /**
     * @var Filesystem
     */
    private $filesystem;
    /**
     * @var string
     */
    private $filePath;

    public function __construct(Filesystem $filesystem, string $filePath)
    {
        $this->filesystem = $filesystem;
        $this->filePath = $filePath;
    }

    public function delete($pathToFile)
    {
        $this->filesystem->remove(
            $this->filePath . '/' . $pathToFile
        );
    }
}

Simple enough. Nothing we haven't seen before.

We're going to use Symfony's filesystem component to remove a file from our server's local hard disk. We will inject the specific path, configured via our service definition:

# /app/config/services.yml

services:

    app.wallpaper_local_filesystem_file_deleter:
        class: AppBundle\Service\FileDeleter
        arguments:
            - "@filesystem"
            - "%wallpaper_file_path%"

To make use of this server inside our WallpaperListener we need to inject it, and then call it:

# /app/config/services.yml

services:

    app.doctrine_event_listener.wallpaper_listener:
        class: AppBundle\Event\Listener\WallpaperListener
        arguments:
            - '@app.wallpaper_uploader'
            - '@app.wallpaper_local_filesystem_file_deleter'
        tags:
            - { name: doctrine.event_listener, event: prePersist }
            - { name: doctrine.event_listener, event: preUpdate }
            - { name: doctrine.event_listener, event: preRemove }

    app.wallpaper_local_filesystem_file_deleter:
        class: AppBundle\Service\LocalFilesystemFileDeleter
        arguments:
            - "@filesystem"
            - "%wallpaper_file_path%"

If we continue with this very concrete approach, the construct function signature is going to get too specific. Let's take a look:

<?php

// src/AppBundle/Event/Listener/WallpaperUploadListener.php

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileUploader;
use AppBundle\Service\LocalFilesystemFileDeleter;

class WallpaperUploadListener
{
    /**
     * @var FileUploader $uploader
     */
    private $uploader;
    /**
     * @var LocalFilesystemFileDeleter $deleter
     */
    private $deleter;

    public function __construct(
        FileUploader $uploader,
        LocalFilesystemFileDeleter $deleter
    )
    {
        $this->uploader = $uploader;
        $this->deleter = $deleter;
    }

    // other stuff removed for brevity

    public function preRemove(LifecycleEventArgs $args)
    {
        // snip
    }
}

As we might go with other implementations, it would be better to use an interface here. Let's refactor:

<?php

// src/AppBundle/Event/Listener/WallpaperUploadListener.php

namespace AppBundle\Event\Listener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Wallpaper;
use AppBundle\Service\FileUploader;
use AppBundle\Service\FileDeleter;

class WallpaperUploadListener
{
    /**
     * @var FileUploader $uploader
     */
    private $uploader;
    /**
     * @var FileDeleter $deleter
     */
    private $deleter;

    public function __construct(
        FileUploader $uploader,
        FileDeleter $deleter
    )
    {
        $this->uploader = $uploader;
        $this->deleter = $deleter;
    }

    // other stuff removed for brevity

    public function preRemove(LifecycleEventArgs $args)
    {
        // snip
    }
}

Things look nicer already. We still have a bit of work to do though:

<?php

// src/AppBundle/Service/LocalFilesystemFileDeleter.php

namespace AppBundle\Service;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\File;

class LocalFilesystemFileDeleter implements FileDeleter
{

All we are doing here is implementing a new FileDeleter interface, which doesn't yet exist. Note no use statement is needed as this interface will live in the same directory as our existing file. Let's create the FileDeleter interface now:

<?php

// src/AppBundle/Service/FileDeleter.php

namespace AppBundle\Service;

interface FileDeleter
{
    public function delete($pathToFile);
}

Cool.

This isn't to say this interface is perfect and won't need changing in the future, but given what we know currently, it should be a good (or good-enough) starting point.

All that remains is to call delete as needed:

    public function preRemove(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();

        if (false === $entity instanceof Wallpaper) {
            return false;
        }

        $this->fileDeleter->delete(
            $entity->getFilename()
        );

        $entity->setFile(null);
    }

Now, try deleting a Wallpaper and you should find the associate image file is deleted along with it. Hurrah.

This essentially concludes the untested approach.

We're now going to switch back to our tested approach and re-do this process to see how things differ. All that remains after this is to add in security to our admin area and phase one of our Wallpaper site will be completed.

Code For This Course

Get the code for this course.

Episodes

# Title Duration
1 Introduction and Site Demo 02:14
2 Setup and a Basic Wallpaper Gallery 08:43
3 Pagination 08:24
4 Adding a Detail View 04:47
5 Creating a Home Page 11:14
6 Creating our Wallpaper Entity 07:50
7 Wallpaper Setup Command - Part 1 - Symfony Commands As a Service 05:57
8 Wallpaper Setup Command - Part 2 - Injection Is Easy 08:53
9 Wallpaper Setup Command - Part 3 - Doing It With Style 05:37
10 Doctrine Fixtures - Part 1 - Setup and Category Entity Creation 08:52
11 Doctrine Fixtures - Part 2 - Relating Wallpapers with Categories 05:56
12 EasyAdminBundle - Setup and Category Configuration 06:02
13 EasyAdminBundle - Wallpaper Setup and List View 07:46
14 EasyAdminBundle - Starting with Wallpaper Uploads 05:57
15 Testing with PhpSpec to Guide Our Implementation 03:39
16 Using PhpSpec to Test our FileMover 05:34
17 Symfony Dependency Testing with PhpSpec 08:47
18 Defensive Counter Measures 06:33
19 No Tests - Part 1 - Uploading Files in EasyAdminBundle 11:01
20 No Tests - Part 2 - Uploading Files in EasyAdminBundle 07:05
21 Don't Mock What You Don't Own 09:36
22 You've Got To Take The Power Back 07:36
23 Making Symfony Work For Us 08:56
24 Testing The Wallpaper File Path Helper 15:11
25 Finally, It Works! 14:56
26 Why I Prefer Not To Use Twig 16:51
27 Fixing The Fixtures 11:20
28 Untested Updates 14:30
29 Untested Updates Part Two - Now We Can Actually Update 06:33
30 Adding an Image Preview On Edit 12:31
31 Delete Should Remove The Wallpaper Image File 11:02
32 Getting Started Testing Wallpaper Updates 10:02
33 Doing The Little Before The Big 08:13
34 Tested Image Preview... Sort Of :) 07:36
35 Finishing Up With a Tested Wallpaper Update 10:41
36 Test Driven Wallpaper Delete - Part 1 11:06
37 Test Driven Wallpaper Delete - Part 2 11:57
38 EasyAdminBundle Login Form Tutorial 08:01