Buy Access to Course
06.

Centralizing Upload Logic

Share this awesome video!

|

We've got a pretty nice system so far: moving the file, unique filenames and putting the filename string into the database. But it is kind of a lot of logic to put in the controller... and we already need to reuse this code somewhere else: in the new() action.

Creating the Service

That's why I like to isolate my upload logic into a service class. In the Service/ directory - or really anywhere - create a new class: how about UploaderHelper?

23 lines | src/Service/UploaderHelper.php
<?php
namespace App\Service;
// ... lines 4 - 6
class UploaderHelper
{
// ... lines 10 - 21
}

This class will handle all things related to uploading files. Create a public function uploadArticleImage(): it will take the UploadedFile as an argument - remember the one from HttpFoundation - and return a string. That will be the string filename that was ultimately saved.

23 lines | src/Service/UploaderHelper.php
// ... lines 1 - 5
use Symfony\Component\HttpFoundation\File\UploadedFile;
// ... line 7
class UploaderHelper
{
public function uploadArticleImage(UploadedFile $uploadedFile): string
{
// ... lines 12 - 20
}
}

Ok! Let's go steal some code for this. In fact, we're going to steal pretty much all the logic here... and paste it in. Make sure to retype the r on Urlizer to get the use statement on top.

23 lines | src/Service/UploaderHelper.php
// ... lines 1 - 4
use Gedmo\Sluggable\Util\Urlizer;
// ... lines 6 - 7
class UploaderHelper
{
public function uploadArticleImage(UploadedFile $uploadedFile): string
{
$destination = $this->getParameter('kernel.project_dir').'/public/uploads/article_image';
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$newFilename = Urlizer::urlize($originalFilename).'-'.uniqid().'.'.$uploadedFile->guessExtension();
$uploadedFile->move(
$destination,
$newFilename
);
}
}

And at the bottom, return $newFilename.

32 lines | src/Service/UploaderHelper.php
// ... lines 1 - 7
class UploaderHelper
{
// ... lines 10 - 16
public function uploadArticleImage(UploadedFile $uploadedFile): string
{
// ... lines 19 - 28
return $newFilename;
}
}

Perfect! Well... not perfect, because the $this->getParameter() method is a shortcut that only works in the controller. If you need a parameter - or any configuration - from inside a service, you need to add it via dependency injection. Add the public function __construct() with, how about, a string $uploadsPath argument. Instead of just injecting the kernel.project_dir parameter, we'll pass in the whole string to where uploads should be stored.

32 lines | src/Service/UploaderHelper.php
// ... lines 1 - 7
class UploaderHelper
{
private $uploadsPath;
public function __construct(string $uploadsPath)
{
$this->uploadsPath = $uploadsPath;
}
// ... lines 16 - 30
}

I'll put my cursor on that argument name, hit Alt + Enter and select initialize fields to create that property and set it. Now, below, we can say $this->uploadsPath and then /article_image.

32 lines | src/Service/UploaderHelper.php
// ... lines 1 - 7
class UploaderHelper
{
// ... lines 10 - 16
public function uploadArticleImage(UploadedFile $uploadedFile): string
{
$destination = $this->uploadsPath.'/article_image';
// ... lines 21 - 29
}
}

Cool! Let's worry about configuring the $uploadsPath argument to our service in a minute. After all, Symfony's service system is so awesome, it'll tell me exactly what I need to configure once we try this.

For now, go back into ArticleAdminController and use this. Start by adding another argument: UploaderHelper $uploaderHelper. And celebrate by removing all of the logic below and replacing it with $newFilename = $uploaderHelper->uploadArticleImage($uploadedFile).

118 lines | src/Controller/ArticleAdminController.php
// ... lines 1 - 7
use App\Service\UploaderHelper;
// ... lines 9 - 17
class ArticleAdminController extends BaseController
{
// ... lines 20 - 49
public function edit(Article $article, Request $request, EntityManagerInterface $em, UploaderHelper $uploaderHelper)
{
// ... lines 52 - 56
if ($form->isSubmitted() && $form->isValid()) {
// ... lines 58 - 59
if ($uploadedFile) {
$newFilename = $uploaderHelper->uploadArticleImage($uploadedFile);
$article->setImageFilename($newFilename);
}
// ... lines 64 - 72
}
// ... lines 74 - 77
}
// ... lines 79 - 116
}

Dang - that is nice! There is still a little bit of logic here: the form logic and the logic that sets the filename on the Article - but I'm comfortable with that. And we now have this great new method: pass it an UploadedFile object, and it'll move it into the correct directory and give it a unique filename.

Binding the $uploadsPath Argument

Let's take it for a test drive! Go back, refresh the form and... it works! Naw, I'm kidding - we knew this error was coming... but isn't it beautiful?

Cannot resolve argument $uploadHelper of the edit() method: Cannot autowire service UploadHelper: argument $uploadsPath of method __construct() is type-hinted string, you should configure its value explicitly.

That's programming poetry people! And it makes sense: autowiring doesn't work for scalar arguments. We got this: open config/services.yaml. We could configure the specific argument for this specific service. But if you've watched our Symfony series, you know that I like to use the bind feature. The argument name is $uploadsPath. So, below _defaults and bind, add $uploadsPath set to %kernel.project_dir%/public/uploads.

47 lines | config/services.yaml
// ... lines 1 - 9
services:
// ... line 11
_defaults:
// ... lines 13 - 19
bind:
// ... lines 21 - 22
$uploadsPath: '%kernel.project_dir%/public/uploads'
// ... lines 24 - 47

This means: anywhere that $uploadsPath is used as an argument for a method that's autowired - usually a controller action or the constructor of a service - pass in this value.

Exceeding upload_max_filesize

Let's go see if that fixed things - reload. Now we see the form. To test this fully, let's empty out the article_image/ directory. This time, let's upload the stars photo. Hit update.

Woh! The file "empty string" does not exist!? What the heck! Let's do some digging. When we call guessExtension(), internally, Symfony looks at the contents of the temporary uploaded file to determine what's inside. But... that file is missing! In fact, PHP is telling us that the temporary file name is... an empty string! It's madness!

Why is this happening? I'll give you a clue: the file we just uploaded is 3mb. Go to your terminal and run

php -i | grep upload

There it is: the upload_max_filesize in my php.ini is 2 megabytes, which is PHP's default value. I have a bunch of things to say about this. First, make sure you set this value to whatever you really want your max to be. You may also need to bump the post_max_size setting - that defaults to 8 mb, and also will cause uploads to fail if they're bigger than this.

Second, if you're getting super weird results while uploading, this is probably the problem. And third, once we add validation to our upload field, we'll get a really nice validation error instead of this crazy fatal error. Symfony has our back.

So let's try a smaller file - our astronaut - it's 1.9 mb. Hit update and... yes! It worked!

Adding the Logic to new() Action

Now that all of our logic is isolated, we can easily repeat this in the new() action. We do need to copy these 5 lines or so, but I'm happy with that.

Up in new(), add the argument - UploaderHelper $uploaderHelper - and inside the isValid() block, paste!

126 lines | src/Controller/ArticleAdminController.php
// ... lines 1 - 17
class ArticleAdminController extends BaseController
{
// ... lines 20 - 23
public function new(EntityManagerInterface $em, Request $request, UploaderHelper $uploaderHelper)
{
// ... lines 26 - 28
if ($form->isSubmitted() && $form->isValid()) {
/** @var Article $article */
$article = $form->getData();
/** @var UploadedFile $uploadedFile */
$uploadedFile = $form['imageFile']->getData();
if ($uploadedFile) {
$newFilename = $uploaderHelper->uploadArticleImage($uploadedFile);
$article->setImageFilename($newFilename);
}
// ... lines 40 - 46
}
// ... lines 48 - 51
}
// ... lines 53 - 124
}

This uses the same form, with the same unmapped field, so it'll all just work.

Next: let's talk about validation.