diff --git a/controller/upload_file.rst b/controller/upload_file.rst index 84c7c5b93c9..2072c4756f7 100644 --- a/controller/upload_file.rst +++ b/controller/upload_file.rst @@ -12,14 +12,13 @@ How to Upload Files integrated with Doctrine ORM, MongoDB ODM, PHPCR ODM and Propel. Imagine that you have a ``Product`` entity in your application and you want to -add a PDF brochure for each product. To do so, add a new property called ``brochure`` -in the ``Product`` entity:: +add a PDF brochure for each product. To do so, add a new property called +``brochureFilename`` in the ``Product`` entity:: // src/AppBundle/Entity/Product.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; - use Symfony\Component\Validator\Constraints as Assert; class Product { @@ -27,29 +26,30 @@ in the ``Product`` entity:: /** * @ORM\Column(type="string") - * - * @Assert\NotBlank(message="Please, upload the product brochure as a PDF file.") - * @Assert\File(mimeTypes={ "application/pdf" }) */ - private $brochure; + private $brochureFilename; - public function getBrochure() + public function getBrochureFilename() { - return $this->brochure; + return $this->brochureFilename; } - public function setBrochure($brochure) + public function setBrochureFilename($brochureFilename) { - $this->brochure = $brochure; + $this->brochureFilename = $brochureFilename; return $this; } } -Note that the type of the ``brochure`` column is ``string`` instead of ``binary`` -or ``blob`` because it just stores the PDF file name instead of the file contents. +Note that the type of the ``brochureFilename`` column is ``string`` instead of +``binary`` or ``blob`` because it only stores the PDF file name instead of the +file contents. -Then, add a new ``brochure`` field to the form that manages the ``Product`` entity:: +The next step is to add a new field to the form that manages the ``Product`` +entity. This must be a ``FileType`` field so the browsers can display the file +upload widget. The trick to make it work is to add the form field as "unmapped", +so Symfony doesn't try to get/set its value from the related entity:: // src/AppBundle/Form/ProductType.php namespace AppBundle\Form; @@ -59,6 +59,7 @@ Then, add a new ``brochure`` field to the form that manages the ``Product`` enti use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; + use Symfony\Component\Validator\Constraints\File; class ProductType extends AbstractType { @@ -66,7 +67,29 @@ Then, add a new ``brochure`` field to the form that manages the ``Product`` enti { $builder // ... - ->add('brochure', FileType::class, ['label' => 'Brochure (PDF file)']) + ->add('brochure', FileType::class, [ + 'label' => 'Brochure (PDF file)', + + // unmapped means that this field is not associated to any entity property + 'mapped' => false, + + // make it optional so you don't have to re-upload the PDF file + // everytime you edit the Product details + 'required' => false, + + // unmapped fields can't define their validation using annotations + // in the associated entity, so you can use the PHP constraint classes + 'constraints' => [ + new File([ + 'maxSize' => '1024k', + 'mimeTypes' => [ + 'application/pdf', + 'application/x-pdf', + ], + 'mimeTypesMessage' => 'Please upload a valid PDF document', + ]) + ], + ]) // ... ; } @@ -103,6 +126,7 @@ Finally, you need to update the code of the controller that handles the form:: use AppBundle\Form\ProductType; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\File\Exception\FileException; + use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; @@ -118,26 +142,32 @@ Finally, you need to update the code of the controller that handles the form:: $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // $file stores the uploaded PDF file - /** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */ - $file = $product->getBrochure(); - - $fileName = $this->generateUniqueFileName().'.'.$file->guessExtension(); - - // Move the file to the directory where brochures are stored - try { - $file->move( - $this->getParameter('brochures_directory'), - $fileName - ); - } catch (FileException $e) { - // ... handle exception if something happens during file upload + /** @var UploadedFile $brochureFile */ + $brochureFile = $form['brochure']->getData(); + + // this condition is needed because the 'brochure' field is not required + // so the PDF file must be processed only when a file is uploaded + if ($brochureFile) { + $originalFilename = pathinfo($brochureFile->getClientOriginalName(), PATHINFO_FILENAME); + // this is needed to safely include the file name as part of the URL + $safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename); + $newFilename = $safeFilename.'-'.uniqid().'.'.$brochureFile->guessExtension(); + + // Move the file to the directory where brochures are stored + try { + $brochureFile->move( + $this->getParameter('brochures_directory'), + $newFilename + ); + } catch (FileException $e) { + // ... handle exception if something happens during file upload + } + + // updates the 'brochureFilename' property to store the PDF file name + // instead of its contents + $product->setBrochureFilename($newFilename); } - // updates the 'brochure' property to store the PDF file name - // instead of its contents - $product->setBrochure($fileName); - // ... persist the $product variable or any other work return $this->redirect($this->generateUrl('app_product_list')); @@ -147,16 +177,6 @@ Finally, you need to update the code of the controller that handles the form:: 'form' => $form->createView(), ]); } - - /** - * @return string - */ - private function generateUniqueFileName() - { - // md5() reduces the similarity of the file names generated by - // uniqid(), which is based on timestamps - return md5(uniqid()); - } } Now, create the ``brochures_directory`` parameter that was used in the @@ -172,9 +192,6 @@ controller to specify the directory in which the brochures should be stored: There are some important things to consider in the code of the above controller: -#. When the form is uploaded, the ``brochure`` property contains the whole PDF - file contents. Since this property stores just the file name, you must set - its new value before persisting the changes of the entity; #. In Symfony applications, uploaded files are objects of the :class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile` class. This class provides methods for the most common operations when dealing with uploaded files; @@ -193,7 +210,7 @@ You can use the following code to link to the PDF brochure of a product: .. code-block:: html+twig - View brochure (PDF) + View brochure (PDF) .. tip:: @@ -206,8 +223,8 @@ You can use the following code to link to the PDF brochure of a product: use Symfony\Component\HttpFoundation\File\File; // ... - $product->setBrochure( - new File($this->getParameter('brochures_directory').'/'.$product->getBrochure()) + $product->setBrochureFilename( + new File($this->getParameter('brochures_directory').'/'.$product->getBrochureFilename()) ); Creating an Uploader Service @@ -233,7 +250,9 @@ logic to a separate service:: public function upload(UploadedFile $file) { - $fileName = md5(uniqid()).'.'.$file->guessExtension(); + $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename); + $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension(); try { $file->move($this->getTargetDirectory(), $fileName); @@ -299,10 +318,12 @@ Now you're ready to use this service in the controller:: // ... if ($form->isSubmitted() && $form->isValid()) { - $file = $product->getBrochure(); - $fileName = $fileUploader->upload($file); - - $product->setBrochure($fileName); + /** @var UploadedFile $brochureFile */ + $brochureFile = $form['brochure']->getData(); + if ($brochureFile) { + $brochureFileName = $fileUploader->upload($brochureFile); + $product->setBrochureFilename($brochureFileName); + } // ... } @@ -313,147 +334,16 @@ Now you're ready to use this service in the controller:: Using a Doctrine Listener ------------------------- -If you are using Doctrine to store the Product entity, you can create a -:doc:`Doctrine listener ` to -automatically move the file when persisting the entity:: - - // src/AppBundle/EventListener/BrochureUploadListener.php - namespace AppBundle\EventListener; - - use AppBundle\Entity\Product; - use AppBundle\Service\FileUploader; - use Doctrine\ORM\Event\LifecycleEventArgs; - use Doctrine\ORM\Event\PreUpdateEventArgs; - use Symfony\Component\HttpFoundation\File\File; - use Symfony\Component\HttpFoundation\File\UploadedFile; - - class BrochureUploadListener - { - private $uploader; - - public function __construct(FileUploader $uploader) - { - $this->uploader = $uploader; - } - - public function prePersist(LifecycleEventArgs $args) - { - $entity = $args->getEntity(); - - $this->uploadFile($entity); - } - - public function preUpdate(PreUpdateEventArgs $args) - { - $entity = $args->getEntity(); - - $this->uploadFile($entity); - } - - private function uploadFile($entity) - { - // upload only works for Product entities - if (!$entity instanceof Product) { - return; - } - - $file = $entity->getBrochure(); - - // only upload new files - if ($file instanceof UploadedFile) { - $fileName = $this->uploader->upload($file); - $entity->setBrochure($fileName); - } elseif ($file instanceof File) { - // prevents the full file path being saved on updates - // as the path is set on the postLoad listener - $entity->setBrochure($file->getFilename()); - } - } - } - -Now, register this class as a Doctrine listener: - -.. configuration-block:: - - .. code-block:: yaml - - # app/config/services.yml - services: - _defaults: - # ... be sure autowiring is enabled - autowire: true - # ... - - AppBundle\EventListener\BrochureUploadListener: - tags: - - { name: doctrine.event_listener, event: prePersist } - - { name: doctrine.event_listener, event: preUpdate } - - .. code-block:: xml - - - - - - - - - - - - - - - - .. code-block:: php - - // app/config/services.php - use AppBundle\EventListener\BrochureUploaderListener; - - $container->autowire(BrochureUploaderListener::class) - ->addTag('doctrine.event_listener', [ - 'event' => 'prePersist', - ]) - ->addTag('doctrine.event_listener', [ - 'event' => 'preUpdate', - ]) - ; - -This listener is now automatically executed when persisting a new Product -entity. This way, you can remove everything related to uploading from the -controller. - -.. tip:: - - This listener can also create the ``File`` instance based on the path when - fetching entities from the database:: - - // ... - use Symfony\Component\HttpFoundation\File\File; - - // ... - class BrochureUploadListener - { - // ... - - public function postLoad(LifecycleEventArgs $args) - { - $entity = $args->getEntity(); +The previous versions of this article explained how to handle file uploads using +:doc:`Doctrine listeners `. However, this +is no longer recommended, because Doctrine events shouldn't be used for your +domain logic. - if (!$entity instanceof Product) { - return; - } - - if ($fileName = $entity->getBrochure()) { - $entity->setBrochure(new File($this->uploader->getTargetDirectory().'/'.$fileName)); - } - } - } +Moreover, Doctrine listeners are often dependent on internal Doctrine behaviour +which may change in future versions. Also, they can introduce performance issues +unawarely (because your listener persists entities which cause other entities to +be changed and persisted). - After adding these lines, configure the listener to also listen for the - ``postLoad`` event. +As an alternative, you can use :doc:`Symfony events, listeners and subscribers `. .. _`VichUploaderBundle`: https://github.com/dustin10/VichUploaderBundle