Login to bookmark this video
Buy Access to Course
20.

Uploading References

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

Unlike 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.

<?php
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.

33 lines | templates/article_admin/edit.html.twig
// ... 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.

127 lines | src/Controller/ArticleAdminController.php
// ... 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.

83 lines | src/Service/UploaderHelper.php
// ... 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).

83 lines | src/Service/UploaderHelper.php
// ... 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?

91 lines | src/Entity/ArticleReference.php
// ... 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...