Skip to content

Commit

Permalink
feature #6040 Remove old File Upload article + improve the new one (W…
Browse files Browse the repository at this point in the history
…outerJ)

This PR was squashed before being merged into the 2.3 branch (closes #6040).

Discussion
----------

Remove old File Upload article + improve the new one

| Q | A
| --- | ---
| Doc fix? | yes
| New docs? | yes
| Applies to | 2.3+
| Fixed tickets | #5375

The old file upload article wasn't good, a new one was written by @javiereguiluz but the old one remained online. This PR removes the old one and documents the missing bits in the new article.

Commits
-------

888c61c Remove old File Upload article + improve the new one
  • Loading branch information
xabbuh committed May 29, 2016
2 parents b9a3606 + 888c61c commit 758e083
Show file tree
Hide file tree
Showing 8 changed files with 310 additions and 618 deletions.
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'
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
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::

// 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);
}
}

Now, register this class as a Doctrine listener:

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

0 comments on commit 758e083

Please sign in to comment.