Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Centralizing Upload Logic

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

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?

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

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

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

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

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

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

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

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

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

Leave a comment!

16
Login or Register to join the conversation

Hello,

I want to upload 3Mb xml file using Microservice. What I do wrong ? Got Error Serialization of 'Symfony\Component\HttpFoundation\File\UploadedFile' is not allowed

`

#[Route('/micro_upload_bank_transaction_iso20022', 'micro_upload_bank_transaction_iso20022')]

public function microBankTransactionImportIso20022(Request $request):RedirectResponse
{
$bankTransactionIso20022File = $request->files->get('bank-transaction-iso20022-file');

if ( $bankTransactionIso20022File instanceof UploadedFile)
{

        $message                        = new SaveBankIsoPayment($bankTransactionIso20022File);
        $this->messageBus->dispatch($message);
        $this->addFlash('success', 'Please wait bank statement import in progress');
    } else {
        $this->addFlash('error', 'Uploaded statement file is not valid or not added');
    }
    return $this->redirectToRoute('app_admin');
}`
Reply
Simon L. Avatar
Simon L. Avatar Simon L. | posted 2 years ago

Hi there,
Great tutorials, thanks.

Please can you explain me why in controllers we can just use $this->getParameter('kernel.project_dir') and why in a service, we need to use depedency injection ? I mean why fundamentally ?

Reply

Hey Simon L.

You can do things like $this->getParameter('kernel.project_dir') in a controller because a controller has access to the Symfony Container. The new standard is to rely on the container as less as possible, that's why we always prefer to inject services, this give us clarity of the dependencies of our services. Also, is possible that you want to instantiate a service with different configuration, i.e. different arguments, so, having said that I recommend you to inject services/parameters as much as possible

Cheers!

1 Reply
Simon L. Avatar

Thanks Diego :)

Reply

Hello,
The only problem I have so far is (every time I hit the Update! Button) that it clears the Content field of the form.

Reply

Hey WilcoS

Do you got any errors? The whole form gets emptied or only the file field?

Reply

No errors at all.
I've downloaded the course and set it up.
When you edit a form by the /admin/article/{id}/edit section and you hit update then the content of the field 'Content' will be erased.

Reply

Interesting... Can you confirm if other fields are saving after submit? Also, double check that you're passing the $article object as second argument to your form $form = $this->createForm(ArticleFormType::class, $article);. When you do that the form autocompletes with the object you passed in.
Btw. when you submit, the controller redirects you back to the same page so you can see the applied changes.

Reply
Stefan D. Avatar
Stefan D. Avatar Stefan D. | posted 4 years ago

Great tutorial. Only one small detail, a method that has side effects should not return a value, so better to create two functions: getFileName(UploadFile $file) : string, upload(UploadFile $file, string $fileName) : void. But this is probably not important for the explanation of the file upload logic.

Reply

Hey Stefan D.!

I think that's a very fair critique :). It's a bit more convenient to "smash" it all into one, but if you like having 2 better, I agree! And of course, having 2 is more flexible (and I would definitely do it that way if this were a reusable lib).

Cheers!

Reply

I get this problem:
Unable to guess the mime type as no guessers are available (Did you enable the php_fileinfo extension?)

Don't know why, ps: I'm using windows

Reply

Hey Giacomo,

Hm, that's interesting... But do you have "fileinfo" PHP extension as it says? You can check it with:
$ php -m | grep fileinfo

Probably mime type guessers require this extension for correct work. Please, install it first and try again.

Cheers!

1 Reply
Nizar Avatar

Hi,
We are still waiting for the continuation of the course

Reply

Hey Nizar

This course is about to be completely released, there are a bunch of episodes after this one, I believe you already know that, so could you be a bit more explicit about what you mean with "continuation of the course".

Cheers!

Reply
Nizar Avatar

Hello,
It remains the last 5 lessons. usually you added the lessons every Wednesday and it's been more than a week that I'm waiting for these lessons
Am I clear or do you want more details yet?

Reply

Hey Ali!

Yes, we're still releasing it! And, actually, I see we release it *every day* not only on Wednesday - you can check it here: https://symfonycasts.com/up... . So, to recap - only 4 videos are left here, so this course will be completely released on the next week!

Thank you for your patience!

P.S. on the course overview page: https://symfonycasts.com/sc... - you can subscribe and we'll inform you by email (only 1 email) as soon as this is completely released. I hope this helps!

Cheers!

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