Skip to content
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

Remove old File Upload article + improve the new one #6040

Closed
wants to merge 10 commits into from
9 changes: 4 additions & 5 deletions book/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1953,18 +1953,17 @@ HTML form so that the user can modify that data. The second goal of a form is to
take the data submitted by the user and to re-apply it to the object.

There's still much more to learn about the powerful world of forms, such as
how to handle
:doc:`file uploads with Doctrine </cookbook/doctrine/file_uploads>` or how
to create a form where a dynamic number of sub-forms can be added (e.g. a
todo list where you can keep adding more fields via JavaScript before submitting).
how to handle :doc:`file uploads </cookbook/controller/upload_file>` or how to
create a form where a dynamic number of sub-forms can be added (e.g. a todo
list where you can keep adding more fields via JavaScript before submitting).
See the cookbook for these topics. Also, be sure to lean on the
:doc:`field type reference documentation </reference/forms/types>`, which
includes examples of how to use each field type and its options.

Learn more from the Cookbook
----------------------------

* :doc:`/cookbook/doctrine/file_uploads`
* :doc:`/cookbook/controller/upload_file`
* :doc:`File Field Reference </reference/forms/types/file>`
* :doc:`Creating Custom Field Types </cookbook/form/create_custom_field_type>`
* :doc:`/cookbook/form/form_customization`
Expand Down
315 changes: 296 additions & 19 deletions cookbook/controller/upload_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,27 @@ Now, update the template that renders the form to display the new ``brochure``
field (the exact template code to add depends on the method used by your application
to :doc:`customize form rendering </cookbook/form/form_customization>`):

.. code-block:: html+twig
.. configuration-block::

{# app/Resources/views/product/new.html.twig #}
<h1>Adding a new product</h1>
.. code-block:: html+twig

{{ form_start() }}
{# ... #}
{# app/Resources/views/product/new.html.twig #}
<h1>Adding a new product</h1>

{{ form_row(form.brochure) }}
{{ form_end() }}
{{ form_start(form) }}
{# ... #}

{{ form_row(form.brochure) }}
{{ form_end(form) }}

.. code-block:: html+php

<!-- app/Resources/views/product/new.html.twig -->
<h1>Adding a new product</h1>

<?php echo $view['form']->start($form) ?>
<?php echo $view['form']->row($form['brochure']) ?>
<?php echo $view['form']->end($form) ?>

Finally, you need to update the code of the controller that handles the form::

Expand All @@ -119,7 +130,7 @@ Finally, you need to update the code of the controller that handles the form::
$form = $this->createForm(new ProductType(), $product);
$form->handleRequest($request);

if ($form->isValid()) {
if ($form->isSubmitted() && $form->isValid()) {
// $file stores the uploaded PDF file
/** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */
$file = $product->getBrochure();
Expand All @@ -128,8 +139,10 @@ Finally, you need to update the code of the controller that handles the form::
$fileName = md5(uniqid()).'.'.$file->guessExtension();

// Move the file to the directory where brochures are stored
$brochuresDir = $this->container->getParameter('kernel.root_dir').'/../web/uploads/brochures';
$file->move($brochuresDir, $fileName);
$file->move(
$this->container->getParameter('brochures_directory'),
$fileName
);

// Update the 'brochure' property to store the PDF file name
// instead of its contents
Expand All @@ -146,16 +159,27 @@ Finally, you need to update the code of the controller that handles the form::
}
}

Now, create the ``brochures_directory`` parameter that was used in the
controller to specify the directory in which the brochures should be stored:

.. code-block:: yaml

# app/config/config.yml

# ...
parameters:
brochures_directory: '%kernel.root_dir%/../web/uploads/brochures'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes more sense in config.yml - it's not a value that would normally need to be changed on a machine-by-machine basis, and devs wouldn't/shouldn't need to worry about configuring it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And actually, we're dependent on it later when we make the paths to it in the Twig template.


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, which
:class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile` class. This class
provides methods for the most common operations when dealing with uploaded files;
#. A well-known security best practice is to never trust the input provided by
users. This also applies to the files uploaded by your visitors. The ``Uploaded``
users. This also applies to the files uploaded by your visitors. The ``UploadedFile``
class provides methods to get the original file extension
(:method:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile::getExtension`),
the original file size (:method:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile::getClientSize`)
Expand All @@ -164,15 +188,268 @@ There are some important things to consider in the code of the above controller:
that information. That's why it's always better to generate a unique name and
use the :method:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile::guessExtension`
method to let Symfony guess the right extension according to the file MIME type;
#. The ``UploadedFile`` class also provides a :method:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile::move`
method to store the file in its intended directory. Defining this directory
path as an application configuration option is considered a good practice that
simplifies the code: ``$this->container->getParameter('brochures_dir')``.

You can now use the following code to link to the PDF brochure of an product:
You can use the following code to link to the PDF brochure of a product:

.. configuration-block::

.. code-block:: html+twig

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

.. code-block:: html+php

<a href="<?php echo $view['assets']->getUrl('uploads/brochures/'.$product->getBrochure()) ?>">
View brochure (PDF)
</a>

.. tip::

When creating a form to edit an already persisted item, the file form type
still expects a :class:`Symfony\\Component\\HttpFoundation\\File\\File`
instance. As the persisted entity now contains only the relative file path,
you first have to concatenate the configured upload path with the stored
filename and create a new ``File`` class::

use Symfony\Component\HttpFoundation\File\File;
// ...

$product->setBrochure(
new File($this->getParameter('brochures_directory').'/'.$product->getBrochure())
);

Creating an Uploader Service
----------------------------

To avoid logic in controllers, making them big, you can extract the upload
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I really like this service, it's small doesn't do much and doesn't seem to make things simpler in any way. However, it felt like a nice step between a big controller and using a Doctrine listener.

logic to a seperate service::

// src/AppBundle/FileUploader.php
namespace AppBundle;

use Symfony\Component\HttpFoundation\File\UploadedFile;

class FileUploader
{
private $targetDir;

public function __construct($targetDir)
{
$this->targetDir = $targetDir;
}

public function upload(UploadedFile $file)
{
$fileName = md5(uniqid()).'.'.$file->guessExtension();

$file->move($this->targetDir, $fileName);

return $fileName;
}
}

Then, define a service for this class:

.. configuration-block::

.. code-block:: yaml

# app/config/services.yml
services:
# ...
app.brochure_uploader:
class: AppBundle\FileUploader
arguments: ['%brochures_directory%']

.. 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
http://symfony.com/schema/dic/services/services-1.0.xsd"
>
<!-- ... -->

<service id="app.brochure_uploader" class="AppBundle\FileUploader">
<argument>%brochures_directory%</argument>
</service>
</container>

.. code-block:: php

// app/config/services.php
use Symfony\Component\DependencyInjection\Definition;

// ...
$container->setDefinition('app.brochure_uploader', new Definition(
'AppBundle\FileUploader',
array('%brochures_directory%')
));

Now you're ready to use this service in the controller::

// src/AppBundle/Controller/ProductController.php

// ...
public function newAction(Request $request)
{
// ...

if ($form->isValid()) {
$file = $product->getBrochure();
$fileName = $this->get('app.brochure_uploader')->upload($file);

$product->setBrochure($fileName);

.. code-block:: html+twig
// ...
}

// ...
}

Using a Doctrine Listener
-------------------------

If you are using Doctrine to store the Product entity, you can create a
:doc:`Doctrine listener </cookbook/doctrine/event_listeners_subscribers>` to
automatically upload the file when persisting the entity::
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondering if we should show code + config, or just link to the other article and just mentioning this as a little tip.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you've handled it here: with code+config (it's really not too long).


// src/AppBundle/EventListener/BrochureUploadListener.php
namespace AppBundle\EventListener;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use AppBundle\Entity\Product;
use AppBundle\FileUploader;

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) {
return;
}

$fileName = $this->uploader->upload($file);
$entity->setBrochure($fileName);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add the preUpdate too? That would be more useable

}

Now, register this class as a Doctrine listener:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question about this. In the popular VichUploaderBundle, they warn you about uploading files in entities which haven't modified any other property. In that case, it won't work and the file will be lost. That's why you need to fake some changes (e.g. updating the updatedtAt property). Would this be a problem here too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, will have to look into this. I think it might is (as doctrine doesn't discover the entity changed)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand the code correctly, the file name will always change when uploaded thus leading to changes in the entity which means that Doctrine is able to detect the change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a test project and this is not a problem with this code.


.. configuration-block::

.. code-block:: yaml

# app/config/services.yml
services:
# ...
app.doctrine_brochure_listener:
class: AppBundle\EventListener\BrochureUploadListener
arguments: ['@app.brochure_uploader']
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
http://symfony.com/schema/dic/services/services-1.0.xsd"
>
<!-- ... -->

<service id="app.doctrine_brochure_listener"
class="AppBundle\EventListener\BrochureUploaderListener"
>
<argument type="service" id="app.brochure_uploader"/>

<tag name="doctrine.event_listener" event="prePersist"/>
<tag name="doctrine.event_listener" event="preUpdate"/>
</service>
</container>

.. code-block:: php

// app/config/services.php
use Symfony\Component\DependencyInjection\Reference;

// ...
$definition = new Definition(
'AppBundle\EventListener\BrochureUploaderListener',
array(new Reference('brochures_directory'))
);
$definition->addTag('doctrine.event_listener', array(
'event' => 'prePersist',
));
$definition->addTag('doctrine.event_listener', array(
'event' => 'preUpdate',
));
$container->setDefinition('app.doctrine_brochure_listener', $definition);

This listeners 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();

$fileName = $entity->getBrochure();

$entity->setBrochure(new File($this->targetPath.'/'.$fileName));
}
}

<a href="{{ asset('uploads/brochures/' ~ product.brochure) }}">View brochure (PDF)</a>
After adding these lines, configure the listener to also listen for the
``postLoad`` event.

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