Skip to content

Commit fbfb48f

Browse files
committed
Merge branch '3.4' into 4.2
* 3.4: Reworded the file upload article to use unmapped fields
2 parents f71ab80 + dea0505 commit fbfb48f

File tree

2 files changed

+94
-206
lines changed

2 files changed

+94
-206
lines changed

controller/upload_file.rst

Lines changed: 84 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -12,44 +12,44 @@ How to Upload Files
1212
integrated with Doctrine ORM, MongoDB ODM, PHPCR ODM and Propel.
1313

1414
Imagine that you have a ``Product`` entity in your application and you want to
15-
add a PDF brochure for each product. To do so, add a new property called ``brochure``
16-
in the ``Product`` entity::
15+
add a PDF brochure for each product. To do so, add a new property called
16+
``brochureFilename`` in the ``Product`` entity::
1717

1818
// src/Entity/Product.php
1919
namespace App\Entity;
2020

2121
use Doctrine\ORM\Mapping as ORM;
22-
use Symfony\Component\Validator\Constraints as Assert;
2322

2423
class Product
2524
{
2625
// ...
2726

2827
/**
2928
* @ORM\Column(type="string")
30-
*
31-
* @Assert\NotBlank(message="Please, upload the product brochure as a PDF file.")
32-
* @Assert\File(mimeTypes={ "application/pdf" })
3329
*/
34-
private $brochure;
30+
private $brochureFilename;
3531

36-
public function getBrochure()
32+
public function getBrochureFilename()
3733
{
38-
return $this->brochure;
34+
return $this->brochureFilename;
3935
}
4036

41-
public function setBrochure($brochure)
37+
public function setBrochureFilename($brochureFilename)
4238
{
43-
$this->brochure = $brochure;
39+
$this->brochureFilename = $brochureFilename;
4440

4541
return $this;
4642
}
4743
}
4844

49-
Note that the type of the ``brochure`` column is ``string`` instead of ``binary``
50-
or ``blob`` because it just stores the PDF file name instead of the file contents.
45+
Note that the type of the ``brochureFilename`` column is ``string`` instead of
46+
``binary`` or ``blob`` because it only stores the PDF file name instead of the
47+
file contents.
5148

52-
Then, add a new ``brochure`` field to the form that manages the ``Product`` entity::
49+
The next step is to add a new field to the form that manages the ``Product``
50+
entity. This must be a ``FileType`` field so the browsers can display the file
51+
upload widget. The trick to make it work is to add the form field as "unmapped",
52+
so Symfony doesn't try to get/set its value from the related entity::
5353

5454
// src/Form/ProductType.php
5555
namespace App\Form;
@@ -59,14 +59,37 @@ Then, add a new ``brochure`` field to the form that manages the ``Product`` enti
5959
use Symfony\Component\Form\Extension\Core\Type\FileType;
6060
use Symfony\Component\Form\FormBuilderInterface;
6161
use Symfony\Component\OptionsResolver\OptionsResolver;
62+
use Symfony\Component\Validator\Constraints\File;
6263

6364
class ProductType extends AbstractType
6465
{
6566
public function buildForm(FormBuilderInterface $builder, array $options)
6667
{
6768
$builder
6869
// ...
69-
->add('brochure', FileType::class, ['label' => 'Brochure (PDF file)'])
70+
->add('brochure', FileType::class, [
71+
'label' => 'Brochure (PDF file)',
72+
73+
// unmapped means that this field is not associated to any entity property
74+
'mapped' => false,
75+
76+
// make it optional so you don't have to re-upload the PDF file
77+
// everytime you edit the Product details
78+
'required' => false,
79+
80+
// unmapped fields can't define their validation using annotations
81+
// in the associated entity, so you can use the PHP constraint classes
82+
'constraints' => [
83+
new File([
84+
'maxSize' => '1024k',
85+
'mimeTypes' => [
86+
'application/pdf',
87+
'application/x-pdf',
88+
],
89+
'mimeTypesMessage' => 'Please upload a valid PDF document',
90+
])
91+
],
92+
])
7093
// ...
7194
;
7295
}
@@ -103,6 +126,7 @@ Finally, you need to update the code of the controller that handles the form::
103126
use App\Form\ProductType;
104127
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
105128
use Symfony\Component\HttpFoundation\File\Exception\FileException;
129+
use Symfony\Component\HttpFoundation\File\UploadedFile;
106130
use Symfony\Component\HttpFoundation\Request;
107131
use Symfony\Component\Routing\Annotation\Route;
108132

@@ -118,26 +142,32 @@ Finally, you need to update the code of the controller that handles the form::
118142
$form->handleRequest($request);
119143

120144
if ($form->isSubmitted() && $form->isValid()) {
121-
// $file stores the uploaded PDF file
122-
/** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */
123-
$file = $product->getBrochure();
124-
125-
$fileName = $this->generateUniqueFileName().'.'.$file->guessExtension();
126-
127-
// Move the file to the directory where brochures are stored
128-
try {
129-
$file->move(
130-
$this->getParameter('brochures_directory'),
131-
$fileName
132-
);
133-
} catch (FileException $e) {
134-
// ... handle exception if something happens during file upload
145+
/** @var UploadedFile $brochureFile */
146+
$brochureFile = $form['brochure']->getData();
147+
148+
// this condition is needed because the 'brochure' field is not required
149+
// so the PDF file must be processed only when a file is uploaded
150+
if ($brochureFile) {
151+
$originalFilename = pathinfo($brochureFile->getClientOriginalName(), PATHINFO_FILENAME);
152+
// this is needed to safely include the file name as part of the URL
153+
$safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename);
154+
$newFilename = $safeFilename.'-'.uniqid().'.'.$brochureFile->guessExtension();
155+
156+
// Move the file to the directory where brochures are stored
157+
try {
158+
$brochureFile->move(
159+
$this->getParameter('brochures_directory'),
160+
$newFilename
161+
);
162+
} catch (FileException $e) {
163+
// ... handle exception if something happens during file upload
164+
}
165+
166+
// updates the 'brochureFilename' property to store the PDF file name
167+
// instead of its contents
168+
$product->setBrochureFilename($newFilename);
135169
}
136170

137-
// updates the 'brochure' property to store the PDF file name
138-
// instead of its contents
139-
$product->setBrochure($fileName);
140-
141171
// ... persist the $product variable or any other work
142172

143173
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::
147177
'form' => $form->createView(),
148178
]);
149179
}
150-
151-
/**
152-
* @return string
153-
*/
154-
private function generateUniqueFileName()
155-
{
156-
// md5() reduces the similarity of the file names generated by
157-
// uniqid(), which is based on timestamps
158-
return md5(uniqid());
159-
}
160180
}
161181

162182
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:
172192
173193
There are some important things to consider in the code of the above controller:
174194

175-
#. When the form is uploaded, the ``brochure`` property contains the whole PDF
176-
file contents. Since this property stores just the file name, you must set
177-
its new value before persisting the changes of the entity;
178195
#. In Symfony applications, uploaded files are objects of the
179196
:class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile` class. This class
180197
provides methods for the most common operations when dealing with uploaded files;
@@ -199,7 +216,7 @@ You can use the following code to link to the PDF brochure of a product:
199216

200217
.. code-block:: html+twig
201218

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

204221
.. tip::
205222

@@ -212,8 +229,8 @@ You can use the following code to link to the PDF brochure of a product:
212229
use Symfony\Component\HttpFoundation\File\File;
213230
// ...
214231

215-
$product->setBrochure(
216-
new File($this->getParameter('brochures_directory').'/'.$product->getBrochure())
232+
$product->setBrochureFilename(
233+
new File($this->getParameter('brochures_directory').'/'.$product->getBrochureFilename())
217234
);
218235

219236
Creating an Uploader Service
@@ -239,7 +256,9 @@ logic to a separate service::
239256

240257
public function upload(UploadedFile $file)
241258
{
242-
$fileName = md5(uniqid()).'.'.$file->guessExtension();
259+
$originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
260+
$safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename);
261+
$fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();
243262

244263
try {
245264
$file->move($this->getTargetDirectory(), $fileName);
@@ -317,10 +336,12 @@ Now you're ready to use this service in the controller::
317336
// ...
318337

319338
if ($form->isSubmitted() && $form->isValid()) {
320-
$file = $product->getBrochure();
321-
$fileName = $fileUploader->upload($file);
322-
323-
$product->setBrochure($fileName);
339+
/** @var UploadedFile $brochureFile */
340+
$brochureFile = $form['brochure']->getData();
341+
if ($brochureFile) {
342+
$brochureFileName = $fileUploader->upload($brochureFile);
343+
$product->setBrochureFilename($brochureFileName);
344+
}
324345

325346
// ...
326347
}
@@ -331,149 +352,16 @@ Now you're ready to use this service in the controller::
331352
Using a Doctrine Listener
332353
-------------------------
333354

334-
If you are using Doctrine to store the Product entity, you can create a
335-
:doc:`Doctrine listener </doctrine/event_listeners_subscribers>` to
336-
automatically move the file when persisting the entity::
337-
338-
// src/EventListener/BrochureUploadListener.php
339-
namespace App\EventListener;
340-
341-
use App\Entity\Product;
342-
use App\Service\FileUploader;
343-
use Doctrine\ORM\Event\LifecycleEventArgs;
344-
use Doctrine\ORM\Event\PreUpdateEventArgs;
345-
use Symfony\Component\HttpFoundation\File\File;
346-
use Symfony\Component\HttpFoundation\File\UploadedFile;
347-
348-
class BrochureUploadListener
349-
{
350-
private $uploader;
351-
352-
public function __construct(FileUploader $uploader)
353-
{
354-
$this->uploader = $uploader;
355-
}
356-
357-
public function prePersist(LifecycleEventArgs $args)
358-
{
359-
$entity = $args->getEntity();
360-
361-
$this->uploadFile($entity);
362-
}
363-
364-
public function preUpdate(PreUpdateEventArgs $args)
365-
{
366-
$entity = $args->getEntity();
367-
368-
$this->uploadFile($entity);
369-
}
370-
371-
private function uploadFile($entity)
372-
{
373-
// upload only works for Product entities
374-
if (!$entity instanceof Product) {
375-
return;
376-
}
377-
378-
$file = $entity->getBrochure();
379-
380-
// only upload new files
381-
if ($file instanceof UploadedFile) {
382-
$fileName = $this->uploader->upload($file);
383-
$entity->setBrochure($fileName);
384-
} elseif ($file instanceof File) {
385-
// prevents the full file path being saved on updates
386-
// as the path is set on the postLoad listener
387-
$entity->setBrochure($file->getFilename());
388-
}
389-
}
390-
}
391-
392-
Now, register this class as a Doctrine listener:
393-
394-
.. configuration-block::
395-
396-
.. code-block:: yaml
355+
The previous versions of this article explained how to handle file uploads using
356+
:doc:`Doctrine listeners </doctrine/event_listeners_subscribers>`. However, this
357+
is no longer recommended, because Doctrine events shouldn't be used for your
358+
domain logic.
397359

398-
# config/services.yaml
399-
services:
400-
_defaults:
401-
# ... be sure autowiring is enabled
402-
autowire: true
403-
# ...
404-
405-
App\EventListener\BrochureUploadListener:
406-
tags:
407-
- { name: doctrine.event_listener, event: prePersist }
408-
- { name: doctrine.event_listener, event: preUpdate }
409-
410-
.. code-block:: xml
411-
412-
<!-- config/services.xml -->
413-
<?xml version="1.0" encoding="UTF-8" ?>
414-
<container xmlns="http://symfony.com/schema/dic/services"
415-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
416-
xsi:schemaLocation="http://symfony.com/schema/dic/services
417-
https://symfony.com/schema/dic/services/services-1.0.xsd">
418-
419-
<services>
420-
<!-- ... be sure autowiring is enabled -->
421-
<defaults autowire="true"/>
422-
<!-- ... -->
423-
424-
<service id="App\EventListener\BrochureUploaderListener">
425-
<tag name="doctrine.event_listener" event="prePersist"/>
426-
<tag name="doctrine.event_listener" event="preUpdate"/>
427-
</service>
428-
</services>
429-
</container>
430-
431-
.. code-block:: php
432-
433-
// config/services.php
434-
use App\EventListener\BrochureUploaderListener;
435-
436-
$container->autowire(BrochureUploaderListener::class)
437-
->addTag('doctrine.event_listener', [
438-
'event' => 'prePersist',
439-
])
440-
->addTag('doctrine.event_listener', [
441-
'event' => 'preUpdate',
442-
])
443-
;
444-
445-
This listener is now automatically executed when persisting a new Product
446-
entity. This way, you can remove everything related to uploading from the
447-
controller.
448-
449-
.. tip::
450-
451-
This listener can also create the ``File`` instance based on the path when
452-
fetching entities from the database::
453-
454-
// ...
455-
use Symfony\Component\HttpFoundation\File\File;
456-
457-
// ...
458-
class BrochureUploadListener
459-
{
460-
// ...
461-
462-
public function postLoad(LifecycleEventArgs $args)
463-
{
464-
$entity = $args->getEntity();
465-
466-
if (!$entity instanceof Product) {
467-
return;
468-
}
469-
470-
if ($fileName = $entity->getBrochure()) {
471-
$entity->setBrochure(new File($this->uploader->getTargetDirectory().'/'.$fileName));
472-
}
473-
}
474-
}
360+
Moreover, Doctrine listeners are often dependent on internal Doctrine behaviour
361+
which may change in future versions. Also, they can introduce performance issues
362+
unawarely (because your listener persists entities which cause other entities to
363+
be changed and persisted).
475364

476-
After adding these lines, configure the listener to also listen for the
477-
``postLoad`` event.
365+
As an alternative, you can use :doc:`Symfony events, listeners and subscribers </event_dispatcher>`.
478366

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

0 commit comments

Comments
 (0)