If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe 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); | |
} | |
} |
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 | |
} |
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); | |
} | |
} |
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!
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.
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!
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!
long story short, when i delete a category, i wan't to delete every file from each blog post :)
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!
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.
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!
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>
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?
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 ?
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!
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
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!
if an Article doesn't have any uploaded files ( or imageFilename is null ) getImagePath ( actual name from tutorial still returns something - UploaderHelper::ARTICLE_IMAGE )
// 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
}
}
It would have been nice to see that doctrine transaction :)
First of it's kind :D