Cómo manejar archivos subidos con Doctrine

Manejar el envío de archivos con entidades Doctrine no es diferente a la manipulación de cualquier otra carga de archivo. En otras palabras, eres libre de mover el archivo en tu controlador después de manipular el envío de un formulario. Para ver ejemplos de cómo hacerlo, consulta el Tipo de campo file en la referencia.

Si lo deseas, también puedes integrar la carga de archivos en el ciclo de vida de tu entidad (es decir, creación, actualización y eliminación). En este caso, ya que tu entidad es creada, actualizada y eliminada desde Doctrine, el proceso de carga y remoción de archivos se llevará a cabo de forma automática (sin necesidad de hacer nada en el controlador);

Para que esto funcione, tendrás que hacerte cargo de una serie de detalles, los cuales serán cubiertos en este artículo del recetario.

Configuración básica

En primer lugar, crea una sencilla clase Entidad de Doctrine con la cual trabajar:

// src/Acme/DemoBundle/Entity/Document.php
namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @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 getAbsolutePath()
    {
        return null === $this->path
            ? null
            : $this->getUploadRootDir().'/'.$this->path;
    }

    public function getWebPath()
    {
        return null === $this->path
            ? null
            : $this->getUploadDir().'/'.$this->path;
    }

    protected function getUploadRootDir()
    {
        // la ruta absoluta del directorio donde se deben
        // guardar los archivos cargados
        return __DIR__.'/../../../../web/'.$this->getUploadDir();
    }

    protected function getUploadDir()
    {
        // se deshace del __DIR__ para no meter la pata
        // al mostrar el documento/imagen cargada en la vista.
        return 'uploads/documents';
    }
}

La entidad Documento tiene un nombre y está asociado con un archivo. La propiedad ruta almacena la ruta relativa al archivo y se persiste en la base de datos. El getAbsolutePath() es un método útil que devuelve la ruta absoluta al archivo, mientras que getWebPath() es un conveniente método que devuelve la ruta web, la cual se utiliza en una plantilla para enlazar el archivo cargado.

Truco

Si no lo has hecho, probablemente primero deberías leer el tipo archivo en la documentación para comprender cómo trabaja el proceso de carga básico.

Nota

Si estás utilizando anotaciones para especificar tus reglas de validación (como muestra este ejemplo), asegúrate de que has habilitado la validación por medio de anotaciones (consulta configurando la validación).

Para manejar el archivo real subido en el formulario, utiliza un campo file «virtual». Por ejemplo, si estás construyendo tu formulario directamente en un controlador, este podría tener el siguiente aspecto:

public function uploadAction()
{
    // ...

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

    // ...
}

A continuación, crea esta propiedad en tu clase Documento y agrega algunas reglas de validación:

use Symfony\Component\HttpFoundation\File\UploadedFile;

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

    /**
     * Sets file.
     *
     * @param UploadedFile $file
     */
    public function setFile(UploadedFile $file = null)
    {
        $this->file = $file;
    }

    /**
     * Get file.
     *
     * @return UploadedFile
     */
    public function getFile()
    {
        return $this->file;
    }
}
  • YAML
    # src/Acme/DemoBundle/Resources/config/validation.yml
    Acme\DemoBundle\Entity\Document:
        properties:
            file:
                - File:
                    maxSize: 6000000
    
  • Annotations
    // src/Acme/DemoBundle/Entity/Document.php
    namespace Acme\DemoBundle\Entity;
    
    // ...
    use Symfony\Component\Validator\Constraints as Assert;
    
    class Document
    {
        /**
         * @Assert\File(maxSize="6000000")
         */
        private $file;
    
        // ...
    }
    
  • XML
    <!-- src/Acme/DemoBundle/Resources/config/validation.yml -->
    <class name="Acme\DemoBundle\Entity\Document">
        <property name="file">
            <constraint name="File">
                <option name="maxSize">6000000</option>
            </constraint>
        </property>
    </class>
    
  • PHP
    // src/Acme/DemoBundle/Entity/Document.php
    namespace Acme\DemoBundle\Entity;
    
    // ...
    use Symfony\Component\Validator\Mapping\ClassMetadata;
    use Symfony\Component\Validator\Constraints as Assert;
    
    class Document
    {
        // ...
    
        public static function loadValidatorMetadata(ClassMetadata $metadata)
        {
            $metadata->addPropertyConstraint('file', new Assert\File(array(
                'maxSize' => 6000000,
            )));
        }
    }
    

Nota

Debido a que estás utilizando la restricción File, Symfony2 automáticamente supone que el campo del formulario es una entrada para cargar un archivo. Es por eso que no lo tienes que establecer explícitamente al crear el formulario anterior (->add('file')).

El siguiente controlador muestra cómo manipular todo el proceso:

// ...
use Acme\DemoBundle\Entity\Document;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
// ...

/**
 * @Template()
 */
public function uploadAction()
{
    $document = new Document();
    $form = $this->createFormBuilder($document)
        ->add('name')
        ->add('file')
        ->getForm()
    ;

    if ($this->getRequest()->isMethod('POST')) {
        $form->bind($this->getRequest());
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();

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

            return $this->redirect($this->generateUrl(...));
        }
    }

    return array('form' => $form->createView());
}

Nota

Al escribir la plantilla, no olvides fijar el atributo enctype:

  • Twig
    <h1>Upload File</h1>
    
    <form action="#" method="post" {{ form_enctype(form) }}>
        {{ form_widget(form) }}
    
        <input type="submit" value="Upload Document" />
    </form>
    
  • PHP
    <h1>Upload File</h1>
    
    <form action="#" method="post" <?php echo $view['form']->enctype($form) ?>>
        <?php echo $view['form']->widget($form) ?>
    
        <input type="submit" value="Upload Document" />
    </form>
    

El controlador anterior automáticamente persistirá la entidad Documento con el nombre presentado, pero no hará nada sobre el archivo y la propiedad path quedará en blanco.

Una manera fácil de manejar la carga de archivos es que lo muevas justo antes de que se persista la entidad y a continuación, establece la propiedad path en consecuencia. Comienza por invocar a un nuevo método upload() en la clase Documento, el cual deberás crear en un momento para manejar la carga del archivo:

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

    $document->upload();

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

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

El método upload() tomará ventaja del objeto Symfony\Component\HttpFoundation\File\UploadedFile, el cual es lo que devuelve después de que se presenta un campo file:

public function upload()
{
    // the file property can be empty if the field is not required
    if (null === $this->getFile()) {
        return;
    }

    // aquí usa el nombre de archivo original pero lo debes
    // sanear al menos para evitar cualquier problema de seguridad

    // move takes the target directory and then the
    // target filename to move to
    $this->getFile()->move(
        $this->getUploadRootDir(),
        $this->getFile()->getClientOriginalName()
    );

    // set the path property to the filename where you've saved the file
    $this->path = $this->getFile()->getClientOriginalName();

    // limpia la propiedad «file» ya que no la necesitas más
    $this->file = null;
}

Usando el ciclo de vida de las retrollamadas

Incluso si esta implementación trabaja, sufre de un importante defecto: ¿Qué pasa si hay un problema al persistir la entidad? El archivo ya se ha movido a su ubicación final, incluso aunque la propiedad path de la entidad no se persista correctamente.

Para evitar estos problemas, debes cambiar la implementación para que la operación de base de datos y el traslado del archivo sean atómicos: si hay un problema al persistir la entidad o si el archivo no se puede mover, entonces, no debe suceder nada.

Para ello, es necesario mover el archivo justo cuando Doctrine persista la entidad a la base de datos. Esto se puede lograr enganchando el ciclo de vida de la entidad a una retrollamada:

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

A continuación, reconstruye la clase Documento para que tome ventaja de estas retrollamadas:

use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 */
class Document
{
    private $temp;

    /**
     * Sets file.
     *
     * @param UploadedFile $file
     */
    public function setFile(UploadedFile $file = null)
    {
        $this->file = $file;
        // check if we have an old image path
        if (isset($this->path)) {
            // store the old name to delete after the update
            $this->temp = $this->path;
            $this->path = null;
        } else {
            $this->path = 'initial';
        }
    }

    /**
     * @ORM\PrePersist()
     * @ORM\PreUpdate()
     */
    public function preUpload()
    {
        if (null !== $this->getFile()) {
            // haz lo que quieras para generar un nombre único
            $filename = sha1(uniqid(mt_rand(), true));
            $this->path = $filename.'.'.$this->getFile()->guessExtension();
        }
    }

    /**
     * @ORM\PostPersist()
     * @ORM\PostUpdate()
     */
    public function upload()
    {
        if (null === $this->getFile()) {
            return;
        }

        // si hay un error al mover el archivo, move() automáticamente
        // envía una excepción. This will properly prevent
        // the entity from being persisted to the database on error
        $this->getFile()->move($this->getUploadRootDir(), $this->path);

        // check if we have an old image
        if (isset($this->temp)) {
            // delete the old image
            unlink($this->getUploadRootDir().'/'.$this->temp);
            // clear the temp image path
            $this->temp = null;
        }
        $this->file = null;
    }

    /**
     * @ORM\PostRemove()
     */
    public function removeUpload()
    {
        if ($file = $this->getAbsolutePath()) {
            unlink($file);
        }
    }
}

La clase ahora hace todo lo que necesitas: genera un nombre de archivo único antes de persistirlo, mueve el archivo después de persistirlo y elimina el archivo si la entidad es eliminada.

Ahora que la entidad maneja automáticamente el movimiento del archivo, debes quitar del controlador la llamada a $document->upload():

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

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

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

Nota

Los eventos retrollamados @ORM\PrePersist() y @ORM\PostPersist() se disparan antes y después de almacenar la entidad en la base de datos. Por otro lado, los eventos retrollamados @ORM\PreUpdate() y @ORM\PostUpdate() se llaman al actualizar la entidad.

Prudencia

Las retrollamadas PreUpdate y PostUpdate sólo se activan si se persiste algún cambio en uno de los campos de la entidad. Esto significa que, de manera predeterminada, si sólo modificas la propiedad $file, estos eventos no se activarán, puesto que esa propiedad no se persiste directamente a través de Doctrine. Una solución sería usar un campo actualizado que Doctrine persista, y modificarlo manualmente al cambiar el archivo.

Usando el id como nombre de archivo

Si deseas utilizar el id como el nombre del archivo, la implementación es un poco diferente conforme sea necesaria para guardar la extensión en la propiedad path, en lugar del nombre de archivo real:

use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 */
class Document
{
    private $temp;

    /**
     * Sets file.
     *
     * @param UploadedFile $file
     */
    public function setFile(UploadedFile $file = null)
    {
        $this->file = $file;
        // check if we have an old image path
        if (is_file($this->getAbsolutePath())) {
            // store the old name to delete after the update
            $this->temp = $this->getAbsolutePath();
        } else {
            $this->path = 'initial';
        }
    }

    /**
     * @ORM\PrePersist()
     * @ORM\PreUpdate()
     */
    public function preUpload()
    {
        if (null !== $this->getFile()) {
            $this->path = $this->getFile()->guessExtension();
        }
    }

    /**
     * @ORM\PostPersist()
     * @ORM\PostUpdate()
     */
    public function upload()
    {
        if (null === $this->getFile()) {
            return;
        }

        // check if we have an old image
        if (isset($this->temp)) {
            // delete the old image
            unlink($this->temp);
            // clear the temp image path
            $this->temp = null;
        }

        // 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->getFile()->move(
            $this->getUploadRootDir(),
            $this->id.'.'.$this->getFile()->guessExtension()
        );

        $this->setFile(null);
    }

    /**
     * @ORM\PreRemove()
     */
    public function storeFilenameForRemove()
    {
        $this->temp = $this->getAbsolutePath();
    }

    /**
     * @ORM\PostRemove()
     */
    public function removeUpload()
    {
        if (isset($this->temp)) {
            unlink($this->temp);
        }
    }

    public function getAbsolutePath()
    {
        return null === $this->path
            ? null
            : $this->getUploadRootDir().'/'.$this->id.'.'.$this->path;
    }
}

Habrás notado en este caso que necesitas trabajar un poco más para poder eliminar el archivo. Antes de eliminarlo, debes almacenar la ruta del archivo (puesto que depende del id). Entonces, una vez que el objeto se ha eliminado completamente de la base de datos, puedes eliminar el archivo (en PostRemove).

Bifúrcame en GitHub