Uploading References
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeUnlike the main form on this page, this form will submit to a different endpoint. And instead of continuing to put more things into ArticleAdminController
, let's create a new controller for everything related to article references: ArticleReferenceAdminController
. Extend BaseController
- that's just a small base controller we created in our Symfony series: it extends the normal AbstractController
. So nothing magic happening there.
namespace App\Controller; | |
// ... lines 4 - 9 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 12 - 19 | |
} |
The Upload Endpoint
Back in the new class, create public function uploadArticleReference()
and, above, @Route
: make sure to get the one from Symfony/Component
. Set the URL to, how about, /admin/article/{id}/references
- where the {id}
is the Article
id that we want to attach the reference to. Add name="admin_article_add_reference"
. Oh, and let's also set methods={"POST"}
.
// ... lines 1 - 4 | |
use App\Entity\Article; | |
// ... line 6 | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\Routing\Annotation\Route; | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
/** | |
* @Route("/admin/article/{id}/references", name="admin_article_add_reference", methods={"POST"}) | |
// ... line 14 | |
*/ | |
public function uploadArticleReference(Article $article, Request $request) | |
{ | |
// ... line 18 | |
} | |
} |
That's optional, but it'll let us create another endpoint later with the same URL that can be used to fetch all the references for a single article.
Let's keep going! Because the article {id}
is in the URL, add an Article $article
argument. Oh, and we need security! You can only upload a file if you have access to edit this article. In our app, we check that with this @IsGranted("MANAGE",
subject="article") annotation, which leverages a custom voter that we created in our Symfony series. It basically makes sure that you are the author of this article or a super admin.
// ... lines 1 - 5 | |
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted; | |
// ... lines 7 - 9 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
/** | |
* @Route("/admin/article/{id}/references", name="admin_article_add_reference", methods={"POST"}) | |
* @IsGranted("MANAGE", subject="article") | |
*/ | |
public function uploadArticleReference(Article $article, Request $request) | |
{ | |
// ... line 18 | |
} | |
} |
Finally, we're ready to fetch the file: add the Request
argument - the one from HttpFoundation
- and let's dd($request->files->get())
and then the name from the input field: reference
.
// ... lines 1 - 9 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
/** | |
* @Route("/admin/article/{id}/references", name="admin_article_add_reference", methods={"POST"}) | |
* @IsGranted("MANAGE", subject="article") | |
*/ | |
public function uploadArticleReference(Article $article, Request $request) | |
{ | |
dd($request->files->get('reference')); | |
} | |
} |
Solid start. Copy the route name and head back to the template. Set the action
attribute to {{ path() }}
, the route name, and for the placeholder part, I'll use multiple lines and pass id
set to article.id
. Oh wait... we don't have an article
variable inside this template. We do have the articleForm
variable, and we could get the Article
from that... but to shorten things, let's properly pass it in.
// ... lines 1 - 2 | |
{% block content_body %} | |
// ... lines 4 - 7 | |
<div class="row"> | |
// ... lines 9 - 14 | |
<div class="col-sm-4"> | |
// ... lines 16 - 17 | |
<form action="{{ path('admin_article_add_reference', { | |
id: article.id | |
}) }}" method="POST" enctype="multipart/form-data"> | |
// ... lines 21 - 22 | |
</form> | |
</div> | |
</div> | |
{% endblock %} | |
// ... lines 27 - 33 |
Find the edit()
action of ArticleAdminController
and pass an article
variable. Now we can say article.id
.
// ... lines 1 - 17 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 20 - 57 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em, UploaderHelper $uploaderHelper) | |
{ | |
// ... lines 60 - 82 | |
return $this->render('article_admin/edit.html.twig', [ | |
// ... line 84 | |
'article' => $article, | |
]); | |
} | |
// ... lines 88 - 125 | |
} |
Phew! Ok, let's check this out: refresh and inspect element on the form. Yep, the URL looks right and the enctype
attribute is there. Ok, try it: select the Symfony Best Practices doc and... upload! Yes! It's our favorite UploadedFile
object!
These article references are special because we need to keep them private: they should only be accessible to the author or a super admin. The process for uploading & downloading private files is, a bit different.
Setting up UploaderHelper
But, we'll start in very similar way: by opening our favorite service, and all-around nice class, UploaderHelper
. Down here, add a new public function uploadArticleReference()
that will have a File
argument and return a string
... pretty much the same as the other method, except that we won't need an $existingFilename
because we won't let ArticleReference
objects be updated. If you want to upload a modified file - cool! Delete the old ArticleReference
and upload a new one. You'll see what I mean as we keep building this out.
// ... lines 1 - 12 | |
class UploaderHelper | |
{ | |
// ... lines 15 - 70 | |
public function uploadArticleReference(File $file): string | |
{ | |
// ... line 73 | |
} | |
// ... lines 75 - 81 | |
} |
To get started, just dd($file)
.
// ... lines 1 - 12 | |
class UploaderHelper | |
{ | |
// ... lines 15 - 70 | |
public function uploadArticleReference(File $file): string | |
{ | |
dd($file); | |
} | |
// ... lines 75 - 81 | |
} |
Back in the controller, let's finish this whole darn thing. Set the file to an $uploadedFile
object and I'll add the same inline documentation that says that this is an UploadedFile
object - the one from HttpFoundation
.
// ... lines 1 - 6 | |
use App\Service\UploaderHelper; | |
use Doctrine\ORM\EntityManagerInterface; | |
// ... line 9 | |
use Symfony\Component\HttpFoundation\File\UploadedFile; | |
// ... lines 11 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $request->files->get('reference'); | |
// ... lines 24 - 37 | |
} | |
} |
Then say $filename =
... oh - we don't have the UploaderHelper
service yet! Add that argument: UploaderHelper $uploaderHelper
. Then $filename = $uploaderHelper->uploadArticleReference($uploadedFile)
.
// ... lines 1 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 24 | |
$filename = $uploaderHelper->uploadArticleReference($uploadedFile); | |
// ... lines 26 - 37 | |
} | |
} |
We know that won't work yet... but if we use our imagination, we know that... someday, it should return the new filename that was stored on the filesystem. To put this value into the database, we need to create a new ArticleReference
object and persist it.
Tightening Up ArticleReference
Oh, but before we do - go open that class. This is a very traditional entity: it has some properties and everything has a getter and a setter. That's great, but because every ArticleReference
needs to have its Article
property set... and because an ArticleReference
will never change articles, find the setArticle()
method and... obliterate it!
Instead, add a public function __construct()
with a required Article
argument. Set that onto the article
property. This is an optional step - but it's always nice to think critically about your entities: what methods do you not need?
// ... lines 1 - 9 | |
class ArticleReference | |
{ | |
// ... lines 12 - 39 | |
public function __construct(Article $article) | |
{ | |
$this->article = $article; | |
} | |
// ... lines 44 - 89 | |
} |
Saving ArticleReference & the Original Filename
Back up in our controller, say $articleReference = new ArticleReference()
and pass $article
. Call $article->setFilename($filename)
to store the unique filename where this file was stored on the filesystem.
// ... lines 1 - 5 | |
use App\Entity\ArticleReference; | |
// ... lines 7 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 26 | |
$articleReference = new ArticleReference($article); | |
$articleReference->setFilename($filename); | |
// ... lines 29 - 37 | |
} | |
} |
But remember! There are a couple of new pieces of info that we can set on ArticleReference
- like the original filename. Set that to $uploadedFile->getClientOriginalName()
. Now, technically this method can return null
, though, I'm not actually sure if that's something that can happen in any realistic scenario. But, just in case, add ??
$filename. So, if the client original name is missing for some reason, fall back to $filename
.
// ... lines 1 - 5 | |
use App\Entity\ArticleReference; | |
// ... lines 7 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 26 | |
$articleReference = new ArticleReference($article); | |
$articleReference->setFilename($filename); | |
$articleReference->setOriginalFilename($uploadedFile->getClientOriginalName() ?? $filename); | |
// ... lines 30 - 37 | |
} | |
} |
Finally, just in case we ever want to know what type of file this is, we'll store the file's mime type. Set this to $uploadedFile->getMimeType()
. This can also return null - so default it to application/octet-stream
, which is sort of a common way to say "I have no idea what this file is".
// ... lines 1 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 26 | |
$articleReference = new ArticleReference($article); | |
$articleReference->setFilename($filename); | |
$articleReference->setOriginalFilename($uploadedFile->getClientOriginalName() ?? $filename); | |
$articleReference->setMimeType($uploadedFile->getMimeType() ?? 'application/octet-stream'); | |
// ... lines 31 - 37 | |
} | |
} |
With that done, save this: add the EntityManagerInterface $entityManager
argument, then $entityManager->persist($articleReference)
and $entityManager->flush()
.
// ... lines 1 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 31 | |
$entityManager->persist($articleReference); | |
$entityManager->flush(); | |
// ... lines 34 - 37 | |
} | |
} |
Finish with return redirectToRoute()
and send the user back to the edit page: admin_article_edit
passing this id
set to $article->getId()
.
// ... lines 1 - 13 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 16 - 19 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 22 - 34 | |
return $this->redirectToRoute('admin_article_edit', [ | |
'id' => $article->getId(), | |
]); | |
} | |
} |
Yep - that's the route on the edit endpoint.
Alright! With any luck, it should hit our dd()
statement. Go back to your browser: I already have the Symfony Best Practices PDF selected. Hit update... yea! UploadedFile
coming from UploaderHelper
.
Next: let's move the uploaded file... except that... we can't move it using the filesystem service object we have now... because we can't store these private files in the public/
directory. Hmm...
Hello,
small question out of context, in these lines :
what's the point of adding the following annotation?