Skip to content

Reworded the file upload article to use unmapped fields #11788

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 27, 2019
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 84 additions & 194 deletions controller/upload_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,44 @@ 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
{
// ...

/**
* @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;
Expand All @@ -59,14 +59,37 @@ 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
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$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',
])
],
])
// ...
;
}
Expand Down Expand Up @@ -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;

Expand All @@ -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'));
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -193,7 +210,7 @@ You can use the following code to link to the PDF brochure of a product:

.. code-block:: html+twig

<a href="{{ asset('uploads/brochures/' ~ product.brochure) }}">View brochure (PDF)</a>
<a href="{{ asset('uploads/brochures/' ~ product.brochureFilename) }}">View brochure (PDF)</a>

.. tip::

Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

// ...
}
Expand All @@ -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 </doctrine/event_listeners_subscribers>` 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

<!-- app/config/config.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
https://symfony.com/schema/dic/services/services-1.0.xsd">

<!-- ... be sure autowiring is enabled -->
<defaults autowire="true"/>
<!-- ... -->

<service id="AppBundle\EventListener\BrochureUploaderListener">
<tag name="doctrine.event_listener" event="prePersist"/>
<tag name="doctrine.event_listener" event="preUpdate"/>
</service>
</container>

.. 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 </doctrine/event_listeners_subscribers>`. 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 </event_dispatcher>`.

.. _`VichUploaderBundle`: https://github.com/dustin10/VichUploaderBundle