Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Deleting Files

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

The next thing our file gallery needs is the ability to delete files. I know this tutorial is all about uploading... but in these chapters, we're sorta, accidentally creating a nice API for our Article references. We already have the ability to get all references for a specific article, create a new reference and download a reference's file. Now we need an endpoint to delete a reference.

Add a new function at the bottom called deleteArticleReference(). Put the @Route() above this with /admin/article/references/{id}, name="admin_article_delete_reference" and - this will be important - methods={"DELETE"}. We do not want to make it possible to make a GET request to this endpoint. First, because that's crazy-dangerous. And second, because if we kept building out the API, we would want to have a different endpoint for making a GET request to /admin/article/references/{id} that would return the JSON for that one reference.

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 115
/**
* @Route("/admin/article/references/{id}", name="admin_article_delete_reference", methods={"DELETE"})
*/
public function deleteArticleReference(ArticleReference $reference)
{
... lines 121 - 122
}
}

Inside, add the ArticleReference $reference argument and then we'll add our normal security check. In fact, copy it from above and put it here.

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 115
/**
* @Route("/admin/article/references/{id}", name="admin_article_delete_reference", methods={"DELETE"})
*/
public function deleteArticleReference(ArticleReference $reference)
{
$article = $reference->getArticle();
$this->denyAccessUnlessGranted('MANAGE', $article);
}
}

The deleteFile() Service Method

Ok: how can we delete a file? Through the magic of Flysystem of course! And the best place for that logic to live is probably UploaderHelper. We already have functions for uploading two types of files, getting the public path and reading a stream. Copy the readStream() function declaration, paste, rename it to deleteFile() and remove the return type.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 83
public function deleteFile(string $path, bool $isPublic)
{
... lines 86 - 92
}
... lines 94 - 121
}

We'll start the same way: by grabbing whichever filesystem we need.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 83
public function deleteFile(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
... lines 87 - 92
}
... lines 94 - 121
}

Next say $result = $filesystem->delete() and pass that $path.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 83
public function deleteFile(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
$result = $filesystem->delete($path);
... lines 89 - 92
}
... lines 94 - 121
}

Finally, code defensively: if $result === false, throw a new exception with Error deleting "%s" and $path.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 83
public function deleteFile(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
$result = $filesystem->delete($path);
if ($result === false) {
throw new \Exception(sprintf('Error deleting "%s"', $path));
}
}
... lines 94 - 121
}

The DELETE Endpoint

That's nice! Back in the controller, add an UploaderHelper argument, oh and we're also going to need the EntityManagerInterface service as well. Remove the reference from the database with $entityManager->remove($reference) and $entityManager->flush(). Then $uploaderHelper->deleteFile() passing that $reference->getFilePath() and false so it uses the private filesystem.

... lines 1 - 19
class ArticleReferenceAdminController extends BaseController
{
... lines 22 - 119
public function deleteArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager)
{
... lines 122 - 124
$entityManager->remove($reference);
$entityManager->flush();
$uploaderHelper->deleteFile($reference->getFilePath(), false);
... lines 129 - 130
}
}

Quick note: in the real world, if there was a problem deleting the file from Flysystem - which is definitely possible when you're storing in the cloud - then you could end up with a situation where the row is deleted in the database, but the file still exists! If you changed the order, you'd have the opposite problem: the file might get deleted, but then the row stays because of a temporary connection error to the database.

If you're worried about this, use a Doctrine transaction to wrap all of this logic. If the file was successfully deleted, commit the transaction. If not, roll it back so both the file and row stay.

Anyways, what should this endpoint return? Well... how about... nothing! Return a new Response() - the one from HttpFoundation - with null as the content and a 204 status code. 204 means: the operation was successful but I have nothing else to say!

... lines 1 - 12
use Symfony\Component\HttpFoundation\Response;
... lines 14 - 19
class ArticleReferenceAdminController extends BaseController
{
... lines 22 - 119
public function deleteArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager)
{
... lines 122 - 129
return new Response(null, 204);
}
}

Hooking up the JavaScript

That's it! That is a nice endpoint! Head back to our JavaScript so we can put this all together. First, down in the render() function, add a little trash icon next to the download link. I'll make this a button... just because semantically, it requires a DELETE request, so it's not something the user can click without JavaScript. Give it a js-reference-delete class so we can find it, some styling classes and, inside, we'll use FontAwesome for the icon.

... lines 1 - 34
class ReferenceList
{
... lines 37 - 74
render() {
const itemsHtml = this.references.map(reference => {
return `
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="${reference.id}">
... lines 79 - 80
<span>
... line 82
<button class="js-reference-delete btn btn-link"><span class="fa fa-trash"></span></button>
</span>
</li>
`
});
... lines 88 - 89
}
}
... lines 92 - 117

Copy that class name and go back up to the constructor. Here say this.$element.on('click') and then pass .js-reference-delete. This is called a delegate event handler. It's handy because it allows us to attach a listener to any .js-reference-delete elements, even if they're added to the HTML after this line is executed. For the callback, I'll pass an ES6 arrow function so that the this variable inside is still my ReferenceList object. Call a new method: this.handleReferenceDelete() and pass it the event object.

... lines 1 - 34
class ReferenceList
{
constructor($element) {
... lines 38 - 41
this.$element.on('click', '.js-reference-delete', (event) => {
this.handleReferenceDelete(event);
});
... lines 45 - 51
}
... lines 53 - 90
}
... lines 92 - 117

Copy that name, head down, and paste to create that. Inside, we need to do two things: make the AJAX request to delete the item from the server and remove the reference from the references array and call this.render() so it disappears.

Start with const $li =. I'm going to use the button that was just clicked to find the <li> element that's around everything - you'll see why in a second. So, const $li = $(event.currentTarget) to get the button that was clicked, then .closest('.list-group-item').

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
... lines 61 - 72
}
... lines 74 - 90
}
... lines 92 - 117

To create the URL for the DELETE request, I need the id of this specific article reference. To get that, add a new data-id attribute on the li set to ${reference.id}. I'm adding this here instead of directly on the button so that we could re-use it for other behaviors.

Now we can say const id = $li.data('id') and $li.addClass('disabled') to make it look like we're doing something during the AJAX call.

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
... lines 63 - 72
}
... lines 74 - 90
}
... lines 92 - 117

Make that with $.ajax() with url() set to '/admin/article/references/'+id and method "DELETE":

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
... line 63
$.ajax({
url: '/admin/article/references/'+id,
method: 'DELETE'
... lines 67 - 71
});
}
... lines 74 - 90
}
... lines 92 - 117

To handle success, chain a .then() on this with another arrow function.

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
... line 63
$.ajax({
url: '/admin/article/references/'+id,
method: 'DELETE'
}).then(() => {
... lines 68 - 71
});
}
... lines 74 - 90
}
... lines 92 - 117

Now that the article reference has been deleted from the server, let's remove it from this.references. A nice way to do that is by saying: this.references = this.references.filter() and passing this an arrow function with return reference.id !== id.

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
... line 63
$.ajax({
url: '/admin/article/references/'+id,
method: 'DELETE'
}).then(() => {
this.references = this.references.filter(reference => {
return reference.id !== id;
});
... line 71
});
}
... lines 74 - 90
}
... lines 92 - 117

This callback function will be called once for each item in the array. If the function returns true, that item will be put into the new references variable. If it returns false, it won't be. The end effect is that we get an identical array, except without the reference that was just deleted.

After this, call this.render().

... lines 1 - 34
class ReferenceList
{
... lines 37 - 58
handleReferenceDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
$.ajax({
url: '/admin/article/references/'+id,
method: 'DELETE'
}).then(() => {
this.references = this.references.filter(reference => {
return reference.id !== id;
});
this.render();
});
}
... lines 74 - 90
}
... lines 92 - 117

Let's try it! Refresh and... cool! There's our delete icon - it looks a little weird, but we'll fix that in a minute. Let's see, in var/uploads we have a rocket.jpeg file. Let's delete that one. Ha! It disappeared! The 204 status code looks good and... the file is gone!

It's strange when things work on the first try!

Alignment Tweak

While we're here, let's fix this alignment issue - it's weirding me out. Down in the render() function, add a few Bootstrap classes to the download link and make the delete button smaller.

Try that. Better... but it's still just a touch off. Add vertical-align: middle to the download icon. It's subtle but... yep - the buttons are lined up now.

... lines 1 - 34
class ReferenceList
{
... lines 37 - 74
render() {
const itemsHtml = this.references.map(reference => {
return `
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="${reference.id}">
... lines 79 - 80
<span>
<a href="/admin/article/references/${reference.id}/download" class="btn btn-link btn-sm"><span class="fa fa-download" style="vertical-align: middle"></span></a>
<button class="js-reference-delete btn btn-link btn-sm"><span class="fa fa-trash"></span></button>
</span>
</li>
`
});
... lines 88 - 89
}
}
... lines 92 - 117

Next: our users are begging for another feature: the ability to rename the file after it's been uploaded.

Leave a comment!

16
Login or Register to join the conversation
Ad F. Avatar

It would have been nice to see that doctrine transaction :)
First of it's kind :D

4 Reply

Hey Ad F.!

Try this ;)


$entityManager->transactional(function(EntityManagerInterface $entityManager) use ($uploaderHelper, $reference) {
    $entityManager->remove($reference);
    $entityManager->flush();

    $uploaderHelper->deleteFile($reference->getFilePath());
});

If removing from the database fails, deleteFile will never be called. If deleteFile fails, the database transaction is rolled back.

Cheers!

7 Reply
Ad F. Avatar

for multiple files delete ?

https://stackoverflow.com/q...

1 Reply

Hey Cybernet2u,

But we add "Delete" button for every file, so if you want to delete multiple files - you just need to press a few "Delete" buttons, right? Or do you want to do it in a single request? Then I'm not sure about your UI, how users will choose what files to delete in a single request? Do you have any ideas? :)

Cheers!

Reply
Ad F. Avatar

long story short, when i delete a category, i wan't to delete every file from each blog post :)

Reply

Hey Cybernet2u,

Ah, I see :) So, what's is your problem? It depends on how much blog posts in your categories are, but you just need to fetch all the blog posts in the category you're about to delete and iterate over each post, and actually remove it. It might be a performance issue if you have a lot of posts.. well, in this case I'd recommend to look into direction of Messenger component to create a queue of deleted blog posts and handle them async. We've already released the course about Messenger component in full, see it here: https://symfonycasts.com/sc...

I hope this helps!

Cheers!

Reply
Nfq A. Avatar

How to check if file is exist then delete file in controller? Cuz I have folder in AWS S3, if the file does not exist, I get an error and I cannot delete the file in the database when I delete that file.

Reply

Hey!

In that case what you need to do is to make an API call to AWS S3 to check if the file indeed exists. This link may give you an idea of how to make the API call: https://stackoverflow.com/a...

Cheers!

Reply
Ad F. Avatar
Ad F. Avatar Ad F. | posted 4 years ago | edited

i found a problem with storing the filepath
getting the path for a file like
<br />return Uploader::BLOG_IMAGE.'/'.$this->getImageFilename();<br />
creates the following problem
if i want to delete an article and I have to check if the article has an image
i would do it like this
`
$files = $post->getBlogReferences();
foreach ($files as $file)
{

         $up->deleteFile($file->getFilePath());

}
`
it will always try to delete <b>Uploader::BLOG_IMAGE</b>

Reply

Hey Ad F.

I don't fully understand your problem. In your example you are passing the file path of a Blog image and that's exactly what you want to do, isn't it?

Reply
Ad F. Avatar

the only way would be to store fullPath, right ?

1 Reply
Ad F. Avatar

yes, thats what i want, but i'm unable to check if $files is null because even if there is no image uploaded ( imageFilename is null ), it will return Uploader::BLOG_IMAGE

does it make sense now ?

1 Reply

Ahh I get it but if you get the ArticleReferences from an Article object, all of its references files should exist, unless it's a new reference but you can check if such object has an ID already set. Well, besided that, what you can do is a file existence check using the Filesystem, or you can add a little bit more of code to ArticleReference::getFilepath() method so you can check if it has something set on its $filename property, if it's not, then return null.

I hope it makes any sense to you :p
Cheers!

1 Reply
Ad F. Avatar

anyway, i will add some code to check only if getImageFilename is null or not
all i was trying to say, is that this tutorial always presumes that the author of an Article will always upload an Image

Reply

Ohh, yea, sometimes is better to skip some edge cases when you are trying to teach something but that's why we have comments :)

Cheers!

Reply
Ad F. Avatar

if an Article doesn't have any uploaded files ( or imageFilename is null ) getImagePath ( actual name from tutorial still returns something - UploaderHelper::ARTICLE_IMAGE )

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3
    }
}
userVoice