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
tonull
) - 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 implement
ing 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.