Skip to content

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

Merged
merged 4 commits into from
Jun 14, 2011
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 6 additions & 3 deletions book/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1146,16 +1146,19 @@ 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 file uploads and 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.
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). See the cookbook for these
topics.

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

* :doc:`Handling File Uploads </cookbook/form/file_uploads>`
* :doc:`Creating Custom Field Types </cookbook/form/create_custom_field_type>`
* :doc:`/cookbook/form/twig_form_customization`
* :doc:`/cookbook/doctrine/file_uploads`

.. _`Symfony2 Form Component`: https://github.com/symfony/Form
.. _`Twig Bridge`: https://github.com/symfony/symfony/tree/master/src/Symfony/Bridge/Twig
Expand Down
284 changes: 284 additions & 0 deletions cookbook/doctrine/file_uploads.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
How to handle File Uploads with Doctrine
========================================

Handling file uploads with Doctrine entities is no much different than
handling any other upload. But if you want to integrate the file upload into
the entity lifecycle (creation, update, and removal), you need to take care of
a lot of details we will talk about in this cookbook entry.

First, let's create a simple Doctrine Entity to work with::

use Doctrine\ORM\Mapping as ORM;

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

/**
* @ORM\Entity
*/
class Document
{
/**
* @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';
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

The 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. Product photo is in /uploads/products, Blog photos in /uploads/blog) and making that information available while handling file uploads and in the view, where you're creating paths to the files.

So, I'm just thinking out loud :)

Copy link
Member

Choose a reason for hiding this comment

The 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.
And for the use of different directories, just add some logic in the getUploadRootDir method, based on another property oof the entity.

}
}

A ``Document`` has a name and it is associated with a file. The ``path``
property stores the relative path to the file and ``getFullPath()`` uses the
``getUploadRootDir()`` to return the absolute path to the document.

.. tip::

If you have not done so yet, you should probably read the
:doc:`file</reference/forms/types/file>` type documentation first to
understand how the basic upload process works.

To receive the uploaded file, we use a "virtual" ``file`` field::

$form = $this->createFormBuilder($document)
->add('name')
->add('file')
->getForm()
;

Validation rules should be declared on this virtual ``file`` property::

use Symfony\Component\Validator\Constraints as Assert;

class Document
{
/**
* @Assert\File(maxSize="6000000")
*/
public $file;

// ...
}

.. note::

As we are using the ``File`` constraint, Symfony2 will automatically guess
that the field is a file upload input; that's why we have not set it
explicitly during form creation.

The following controller shows you how to manage the form::

public function uploadAction(Post $post)
{
$document = new Document();
$form = $this->createFormBuilder($document)
->add('name')
->add('file')
->getForm()
;

if ($this->getRequest()->getMethod() === 'POST') {
$form->bindRequest($this->getRequest());
if ($form->isValid()) {
$em = $this->getDoctrine()->getEntityManager();

$em->persist($document);
$em->flush();

$this->redirect('...');
}
}

return array('post' => $post, '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 code will automatically persist document entities with their
names, but it will do nothing about the file, because it is not managed by
Doctrine. However, moving the file can be done just before the document is
persisted to the database by calling the ``move()`` method of the
:class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile` instance
returned for the ``file`` field when the form is submitted::

if ($form->isValid()) {
$em = $this->getDoctrine()->getEntityManager();

$document->upload();

$em->persist($document);
$em->flush();

$this->redirect('...');
}

And here is the implementation of the ``upload`` method::

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 at least it to avoid any security issues
$this->file->move($this->getUploadRootDir(), $this->file->getOriginalName());

$this->setPath($this->file->getOriginalName());

// clean up the file property as we won't need it anymore
unset($this->file);
}

Even if this implementation works, it suffers from a major flaw: What if there
is a problem when the entity is persisted? The file is already moved to its
final location but the entity still references the previous file.

To avoid these issues, we are going to change the implementation so that the
database operation and the moving of the file becomes atomic: if there is a
problem when persisting the entity or if the file cannot be moved, then
nothing happens.

To make the operation atomic, we need to do the moving of the file when
Doctrine persists the entity to the database. This can be accomplished by
hooking into the entity lifecycle::

/**
* @ORM\Entity
* @ORM\HasLifecycleCallbacks
*/
class Document
{
}

And here is the ``Document`` class that shows the final version with all
lifecycle callbacks implemented::

use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
* @ORM\Entity
* @ORM\HasLifecycleCallbacks
*/
class Document
{
/**
* @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
$this->file->move($this->getUploadRootDir(), $this->path);

unset($this->file);
}

/**
* @ORM\PostRemove()
*/
public function removeUpload()
{
if ($file = $this->getFullPath()) {
Copy link
Member

Choose a reason for hiding this comment

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

this method is not defined.

Copy link
Member Author

Choose a reason for hiding this comment

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

getFullPath() is defined here: 8a437aa#L1R35

Copy link
Member

Choose a reason for hiding this comment

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

ah sorry, missed that.

unlink($file);
}
}
}

If you want to use the ``id`` as the name of the file, the implementation is
slightly different as we need to save the extension under the ``path``
property, instead of the path::

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;
}
}
68 changes: 67 additions & 1 deletion reference/forms/types/file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,70 @@
file Field Type
===============

See :class:`Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType`.
The ``file`` type represents a file input in your form.

+-------------+---------------------------------------------------------------------+
| Rendered as | ``input`` ``file`` field |
+-------------+---------------------------------------------------------------------+
| Options | none |
+-------------+---------------------------------------------------------------------+
| Parent type | :doc:`form</reference/forms/types/field>` |
+-------------+---------------------------------------------------------------------+
| Class | :class:`Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType` |
+-------------+---------------------------------------------------------------------+

Basic Usage
-----------

Let's say you have this form definition:

.. code-block:: php

$builder->add('attachment', 'file');

.. caution::

Don't forget to add the ``enctype`` attribute in the form tag: ``<form
action="#" method="post" {{ form_enctype(form) }}>``.

When the form is submitted, the document element will be an instance of
:class:`Symfony\\Component\\HttpFoundation\\File\\UploadedFile`. It can be
used to move the `attachment` file to a permanent location:

.. code-block:: php

use Symfony\Component\HttpFoundation\File\UploadedFile;

public function uploadAction()
{
// ...

if ($form->isValid()) {
$form['attachment']->move($dir, $file);

// ...
}

// ...
}

The ``move()`` method takes a directory and a file name as arguments::

// use the original file name
$file->move($dir, $this->getOriginalName());

// compute a random name and try to guess the extension (more secure)
$extension = $file->guessExtension();
if (!$extension) {
// extension cannot be guessed
$extension = 'bin';
}
$file->move($dir, rand(1, 99999).'.'.$extension);

Using the original name via ``getOriginalName()`` is not safe as it can have
been manipulated by the end-user. Moreover, it can contain characters that are
not allowed in file names. You should sanitize the name before using it
directly.

Read the :doc:`cookbook </cookbook/doctrine/file_uploads>` for an example of
how to manage a file upload associated with a Doctrine entity.