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 all 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
11 changes: 7 additions & 4 deletions book/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1146,14 +1146,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 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:`/cookbook/doctrine/file_uploads`
* :doc:`File Field Reference </reference/forms/types/file>`
* :doc:`Creating Custom Field Types </cookbook/form/create_custom_field_type>`
* :doc:`/cookbook/form/twig_form_customization`

Expand Down
336 changes: 336 additions & 0 deletions cookbook/doctrine/file_uploads.rst
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;

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

}
}

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

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;
}
}
5 changes: 0 additions & 5 deletions cookbook/form/file_uploads.rst

This file was deleted.

2 changes: 1 addition & 1 deletion cookbook/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Cookbook
doctrine/doctrine_fixtures
doctrine/mongodb
doctrine/migrations
doctrine/file_uploads
doctrine/common_extensions
doctrine/event_listeners_subscribers
doctrine/reverse_engineering
Expand All @@ -24,7 +25,6 @@ Cookbook

form/twig_form_customization
form/create_custom_field_type
form/file_uploads
validation/custom_constraint

configuration/environments
Expand Down
3 changes: 2 additions & 1 deletion cookbook/map.rst.inc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* :doc:`/cookbook/doctrine/doctrine_fixtures`
* :doc:`/cookbook/doctrine/mongodb`
* :doc:`/cookbook/doctrine/migrations`
* :doc:`/cookbook/doctrine/file_uploads`
* :doc:`/cookbook/doctrine/common_extensions`
* :doc:`/cookbook/doctrine/event_listeners_subscribers`
* :doc:`/cookbook/doctrine/dbal`
Expand All @@ -28,8 +29,8 @@

* :doc:`/cookbook/form/twig_form_customization`
* :doc:`/cookbook/form/create_custom_field_type`
* :doc:`/cookbook/form/file_uploads`
* :doc:`/cookbook/validation/custom_constraint`
* (doctrine) :doc:`/cookbook/doctrine/file_uploads`

* **Configuration and the Service Container**

Expand Down
Loading