-
-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Form file uploads #400
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
Form file uploads #400
Changes from all commits
02543e4
8a437aa
f220cd1
9479539
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,336 @@ | ||
How to handle File Uploads with Doctrine | ||
======================================== | ||
|
||
Handling file uploads with Doctrine entities is no different than handling | ||
any other file upload. In other words, you're free to move the file in your | ||
controller after handling a form submission. For examples of how to do this, | ||
see the :doc:`file type reference</reference/forms/types/file>` page. | ||
|
||
If you choose to, you can also integrate the file upload into your entity | ||
lifecycle (i.e. creation, update and removal). In this case, as your entity | ||
is created, updated, and removed from Doctrine, the file uploading and removal | ||
processing will take place automatically (without needing to do anything in | ||
your controller); | ||
|
||
To make this work, you'll need to take care of a number of details, which | ||
will be covered in this cookbook entry. | ||
|
||
Basic Setup | ||
----------- | ||
|
||
First, create a simple Doctrine Entity class to work with:: | ||
|
||
// src/Acme/DemoBundle/Entity/Download.php | ||
namespace Acme\DemoBundle\Entity; | ||
|
||
use Doctrine\ORM\Mapping as ORM; | ||
use Symfony\Component\Validator\Constraints as Assert; | ||
|
||
/** | ||
* @ORM\Entity | ||
*/ | ||
class Download | ||
{ | ||
/** | ||
* @ORM\Id @ORM\Column(type="integer") | ||
* @ORM\GeneratedValue(strategy="AUTO") | ||
*/ | ||
public $id; | ||
|
||
/** | ||
* @ORM\Column(type="string", length=255) | ||
* @Assert\NotBlank | ||
*/ | ||
public $name; | ||
|
||
/** | ||
* @ORM\Column(type="string", length=255, nullable=true) | ||
*/ | ||
public $path; | ||
|
||
public function getFullPath() | ||
{ | ||
return null === $this->path ? null : $this->getUploadRootDir().'/'.$this->path; | ||
} | ||
|
||
protected function getUploadRootDir() | ||
{ | ||
return '/path/to/uploaded/documents'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like a hack - how could we have a hard-coded path included like this? I've been doing something similar to this, but have injected this information (which is quite unfortunate). Overall. I think this highlights the issue with trying to handle all of this from within an entity. It seems like it might be more appropriate to hand all of this off to some other service - which could handle the unique filename stuff, the moving of the files, setting of the path. In theory - though I don't see exactly how - that service could listen on Doctrine and respond to entities that implement a certain interface (with methods that give information on how to get file information). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way we've been dealing with this is by using a service to handle it, as you say. It means however, that we've set our Entity to explicit flush type so that EM flushes wont touch the entity unless our service wants them to. No doctrine listener, just a coding standard that everything must use its manager service instead of raw doctrine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to draw the line somewhere. This cookbook entry is just a starting point for you own implementation. I've tried to find a good balance between correctness and simplicity. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the cookbook entry is very good - it builds the details nicely. I just think that the cookbook entry highlights a missing feature - be it in a bundle or in core - which removes the need for the boilerplate code and replaces it with some simple configuration (choose target directory, choose filename generation method, specify "file" field, specify "path" field...). It's not really a concern for the framework, but from a practical standpoint, there's also the issue of managing exactly which files you have where (e.g. So, I'm just thinking out loud :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @weaverryan Making it configurable will require using the lifecycle event instead of the lifecycle callback. Once the logic is moved to a listener, the service can also be used in the view. |
||
} | ||
} | ||
|
||
The ``Download`` entity has a name and it is associated with a file. The ``path`` | ||
property stores the relative path to the file and is persisted to the database. | ||
The ``getFullPath()`` is a convenience method that uses the ``getUploadRootDir()`` | ||
method to return the absolute path to the download file. | ||
|
||
.. tip:: | ||
|
||
If you have not done so already, you should probably read the | ||
:doc:`file</reference/forms/types/file>` type documentation first to | ||
understand how the basic upload process works. | ||
|
||
To handle the actual file upload in the form, use a "virtual" ``file`` field. | ||
For example, if you're building your form directly in a controller, it might | ||
look like this:: | ||
|
||
public function uploadAction() | ||
{ | ||
// ... | ||
|
||
$form = $this->createFormBuilder($document) | ||
->add('name') | ||
->add('file') | ||
->getForm() | ||
; | ||
|
||
// ... | ||
} | ||
|
||
Next, create this property on your ``Download`` class and add some validation | ||
rules:: | ||
|
||
// src/Acme/DemoBundle/Entity/Download.php | ||
|
||
// ... | ||
class Download | ||
{ | ||
/** | ||
* @Assert\File(maxSize="6000000") | ||
*/ | ||
public $file; | ||
|
||
// ... | ||
} | ||
|
||
.. note:: | ||
|
||
As you are using the ``File`` constraint, Symfony2 will automatically guess | ||
that the form field is a file upload input. That's why you did not have | ||
to set it explicitly when creating the form above (``->add('file')``). | ||
|
||
The following controller shows you how to handle the entire process:: | ||
|
||
use Acme\DemoBundle\Entity\Download; | ||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; | ||
// ... | ||
|
||
/** | ||
* @Template() | ||
*/ | ||
public function uploadAction() | ||
{ | ||
$download = new Download(); | ||
$form = $this->createFormBuilder($download) | ||
->add('name') | ||
->add('file') | ||
->getForm() | ||
; | ||
|
||
if ($this->getRequest()->getMethod() === 'POST') { | ||
$form->bindRequest($this->getRequest()); | ||
if ($form->isValid()) { | ||
$em = $this->getDoctrine()->getEntityManager(); | ||
|
||
$em->persist($download); | ||
$em->flush(); | ||
|
||
$this->redirect($this->generateUrl('...')); | ||
} | ||
} | ||
|
||
return array('form' => $form->createView()); | ||
} | ||
|
||
.. note:: | ||
|
||
When writing the template, don't forget to set the ``enctype`` attribute: | ||
|
||
.. code-block:: html+php | ||
|
||
<h1>Upload File</h1> | ||
|
||
<form action="#" method="post" {{ form_enctype(form) }}> | ||
{{ form_widget(form) }} | ||
|
||
<input type="submit" value="Upload Document" /> | ||
</form> | ||
|
||
The previous controller will automatically persist the ``Download`` entity | ||
with the submitted name, but it will do nothing about the file and the ``path`` | ||
property will be blank. | ||
|
||
An easy way to handle the file upload is to move it just before the entity is | ||
persisted and then set the ``path`` property accordingly. Start by calling | ||
a new ``upload()`` method on the ``Download`` class, which you'll create | ||
in a moment to handle the file upload:: | ||
|
||
if ($form->isValid()) { | ||
$em = $this->getDoctrine()->getEntityManager(); | ||
|
||
$document->upload(); | ||
|
||
$em->persist($document); | ||
$em->flush(); | ||
|
||
$this->redirect('...'); | ||
} | ||
|
||
The ``upload()`` method will take advantage of the :class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile` | ||
object, which is what's returned after a ``file`` field is submitted:: | ||
|
||
public function upload() | ||
{ | ||
// the file property can be empty if the field is not required | ||
if (!$this->file) { | ||
return; | ||
} | ||
|
||
// we use the original file name here but you should | ||
// sanitize it at least to avoid any security issues | ||
|
||
// move takes the target directory and then the target filename to move to | ||
$this->file->move($this->getUploadRootDir(), $this->file->getOriginalName()); | ||
|
||
// set the path property to the filename where you'ved saved the file | ||
$this->setPath($this->file->getOriginalName()); | ||
|
||
// clean up the file property as you won't need it anymore | ||
unset($this->file); | ||
} | ||
|
||
Using Lifecycle Callbacks | ||
------------------------- | ||
|
||
Even if this implementation works, it suffers from a major flaw: What if there | ||
is a problem when the entity is persisted? The file would have already moved | ||
to its final location even though the entity's ``path`` property didn't | ||
persist correctly. | ||
|
||
To avoid these issues, you should change the implementation so that the database | ||
operation and the moving of the file become atomic: if there is a problem | ||
persisting the entity or if the file cannot be moved, then *nothing* should | ||
happen. | ||
|
||
To do this, you need to move the file right as Doctrine persists the entity | ||
to the database. This can be accomplished by hooking into an entity lifecycle | ||
callback:: | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\HasLifecycleCallbacks | ||
*/ | ||
class Download | ||
{ | ||
} | ||
|
||
Next, refactor the ``Download`` class to take advantage of these callbacks:: | ||
|
||
use Symfony\Component\HttpFoundation\File\UploadedFile; | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\HasLifecycleCallbacks | ||
*/ | ||
class Download | ||
{ | ||
/** | ||
* @ORM\PrePersist() | ||
*/ | ||
public function preUpload() | ||
{ | ||
if ($this->file) { | ||
// do whatever you want to generate a unique name | ||
$this->setPath(uniq().'.'.$this->file->guessExtension()); | ||
} | ||
} | ||
|
||
/** | ||
* @ORM\PostPersist() | ||
*/ | ||
public function upload() | ||
{ | ||
if (!$this->file) { | ||
return; | ||
} | ||
|
||
// you must throw an exception here if the file cannot be moved | ||
// so that the entity is not persisted to the database | ||
// which the UploadedFile move() method does automatically | ||
$this->file->move($this->getUploadRootDir(), $this->path); | ||
|
||
unset($this->file); | ||
} | ||
|
||
/** | ||
* @ORM\PostRemove() | ||
*/ | ||
public function removeUpload() | ||
{ | ||
if ($file = $this->getFullPath()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this method is not defined. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. getFullPath() is defined here: 8a437aa#L1R35 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah sorry, missed that. |
||
unlink($file); | ||
} | ||
} | ||
} | ||
|
||
The class now does everything you need: it generates a unique filename before | ||
persisting, moves the file after persisting, and removes the file if the | ||
entity is ever deleted. | ||
|
||
Using the ``id`` as the filename | ||
-------------------------------- | ||
|
||
If you want to use the ``id`` as the name of the file, the implementation is | ||
slightly different as you need to save the extension under the ``path`` | ||
property, instead of the actual filename:: | ||
|
||
use Symfony\Component\HttpFoundation\File\UploadedFile; | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\HasLifecycleCallbacks | ||
*/ | ||
class Document | ||
{ | ||
/** | ||
* @ORM\PrePersist() | ||
*/ | ||
public function preUpload() | ||
{ | ||
if ($this->file) { | ||
$this->setPath($this->file->guessExtension()); | ||
} | ||
} | ||
|
||
/** | ||
* @ORM\PostPersist() | ||
*/ | ||
public function upload() | ||
{ | ||
if (!$this->file) { | ||
return; | ||
} | ||
|
||
// you must throw an exception here if the file cannot be moved | ||
// so that the entity is not persisted to the database | ||
// which the UploadedFile move() method does | ||
$this->file->move($this->getUploadRootDir(), $this->id.'.'.$this->file->guessExtension()); | ||
|
||
unset($this->file); | ||
} | ||
|
||
/** | ||
* @ORM\PostRemove() | ||
*/ | ||
public function removeUpload() | ||
{ | ||
if ($file = $this->getFullPath()) { | ||
unlink($file); | ||
} | ||
} | ||
|
||
public function getFullPath() | ||
{ | ||
return null === $this->path ? null : $this->getUploadRootDir().'/'.$this->id.'.'.$this->path; | ||
} | ||
} |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess you need to add
use Symfony\Component\Validator\Constraints as Assert;
since there's an @Assert in used in line 26