Subida de archivos en Doctrine

Con entidades Doctrine es fácil administrar la subida de archivos y poder manejarla con el ciclo de la entidad: creación, actualización y eliminación

El manejo de archivos con entidades Doctrine no es diferente al manejo de otro tipo de subida de archivos. Si quieres puedes manejar el archivo en un controller después de un envío de formulario.

Podemos integrar la subida de archivo en el ciclo de la entidad (creación, actualización y eliminación). De esta forma, cuando tu entidad sea creada, actualizada o eliminada de Doctrine, la subida de archivos y el proceso de eliminación tendrá lugar de forma automática (sin tener que hacer nada en el controller).

Indice de contenido

  1. La clase
  2. Lifecycle Callbacks
  3. Emplear el id como nombre de archivo

1. La clase

namespace AppBundle\Entity;

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

/**
 * Class Document
 * @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()
    {
        // el directorio absoluto donde se guardan los documentos
        return __DIR__.'/../../../../web/'.$this->getUploadDir();
    }

    protected function getUploadDir()
    {
        // Eliminamos el __DIR__ para evitar errores al mostrar en view
        return 'uploads/documents';
    }
}

La entidad Document tiene un nombre y está asociada con un archivo. La propiedad path guarda el directorio relativo al archivo y se guarda en la base de datos. El método getAbsolutePath() devuelve el directorio absoluto y getWebPath() devuelve el directorio web, para poder usarse en la template y enlazar al archivo subido.

Para manejar la subida del archivo en el formulario, usamos un campo file. Después crearemos el formulario directamente en el controller por razones de simplicidad.

Primero creamos la propiedad $file en la entidad:

// src/AppBundle/Entity/Document.php
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;

// ...
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;
    }
}

Como estamos usando el constraint File, Symfony automáticamente sabrá que el campo del formulario es un input para la subida de archivos. Por esta razón no lo tenemos que establecer explícitamente cuando creemos el formulario ahora:

// ...
use AppBundle\Entity\Document;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;
// ...

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

    $form->handleRequest($request);

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

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

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

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

Este controller automáticamente guardará la entidad Document con el nombre enviado, pero no hará nada con respecto al archivo y la propiedad path estará vacía.

Una forma fácil de manejar la subida de archivos es moverlo justo antes de que la entidad sea persistida y entonces establecer la propiedad path. Vamos a incluir un método upload() en la clase Document:

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

    $document->upload();

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

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

El método upload aprovechará el objeto UploadedFile, que es lo que es devuelto una vez que el campo file se ha enviado:

public function upload()
{
    // la propiedad file puede quedar vacía
    if(null === $this->getFile()){
        return;
    }

    // se usa el nombre original aquí pero debería sanitizarse
    // el método move recibe el directorio y el archivo
    $this->getFile()->move(
        $this->getUploadRootDir(),
        $this->getFile()->getClientOriginalName()
    );

    // establecemos la propiedad path con el nombre de archivo
    $this->path = $this->getFile()->getClientOriginalName();

    // limpiamos la propiedad file pues ya no la necesitamos
    $this->file = null;
}

2. Lifecycle callbacks

Los lifetime callbacks son una técnica de uso limitado que tiene algunos inconvenientes. Para eliminar la referencia DIR del método Document::getUploadRootDir(), la mejor forma es emplear doctrine listeners. Así podrás inyectar parámetros kernel como kernel.root_dir para construir paths absolutos.

El ejemplo anterior funciona, pero si hay un problema cuando se persista la entidad, el archivo se habrá movido igualmente al directorio final, incluso cuando la propiedad path de la entidad no haya persistido correctamente.

Para evitar estos problemas, tenemos que cambiar la implementación de forma que la operación en la base de datos y mover el archivo son más dependientes: si hay un problema en cualquiera de las dos, se cancela la operación entera.

Para hacer esto, tenemos que mover el archivo cuando Doctrine persista la entidad en la base de datos. Esto se puede hacer en un lifecycle callvack:

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

Ahora refactorizamos la clase Document para aprovechar los callbacks:

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;
        // comprobamos si tenemos un image path antiguo
        if (isset($this->path)) {
            // guardamos el nombre antiguo para borrarlo después del update
            $this->temp = $this->path;
            $this->path = null;
        } else {
            $this->path = 'initial';
        }
    }

    /**
     * @ORM\PrePersist()
     * @ORM\PreUpdate()
     */
    public function preUpload()
    {
        if (null !== $this->getFile()) {
            // hacemos cualquier cosa para generar el 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 algún error cuando se mueva el archivo, se lanzará una excepción
        // automáticamente con move(). Esto prevendrá a la entidad
        // de ser persistida en la base de datos cuando haya error
        $this->getFile()->move($this->getUploadRootDir(), $this->path);

        // comprobar si tenemos una imagen vieja
        if (isset($this->temp)) {
            // borrar esa imagen
            unlink($this->getUploadRootDir().'/'.$this->temp);
            // limpiar el image path temporal
            $this->temp = null;
        }
        $this->file = null;
    }

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

Si los cambios en la entidad son manejados por un event listener o subscriber de Doctrine, el callback preUpdate() debe notificar a Doctrine sobre los cambios que se hacen. Para más detalles sobre las restricciones del evento preUpdate, mira la documentación.

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

Ahora que la entidad maneja el desplazamiento del archivo, la llamada a $document->upload() se ha de eliminar:

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

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

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

Los callbacks @ORM\PrePersist() y @ORM\PostPersist() se lanzan antes y después de que la entidad se persista en la base de datos. Por otra parte, @ORM\PreUpdate() y @ORM\PostUpdate se llaman cuando la entidad se actualiza.

Los callbacks PreUpdate y PostUpdate se lanzan sólo si hay un cambio en uno de los campos de las entidades que se persisten. Esto significa que, por defecto, si modificas sólo la propiedad $file, estos eventos no serán lanzados, ya que la propiedad en sí no se persiste directamente desde Doctrine. Una solución sería emplear un campo updated que es persistido por Doctrine, y modificarlo manualmente cuando se cambie el archivo.

3. Emplear el id como nombre de archivo

Si quieres usar el id como el nombre del archivo, la implementación es algo diferente ya que necesitas guardar la extensión bajo la propiedad path, en lugar del nombre de archivo:

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;
        // comprobamos si tenemos un image path antiguo
        if (is_file($this->getAbsolutePath())) {
            // guardamos el nombre antiguo para borrarlo después del update
            $this->temp = $this->getAbsolutePath();
            $this->path = null;
        } 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;
        }

        // comprobamos si tenemos una imagen antigua
        if (isset($this->temp)) {
            // borramos imagen antigua
            unlink($this->temp);
            // limpiamos el image path temporal
            $this->temp = null;
        }

        // debemos lanzar una excepción aquí si el archivo no puede moverse
        // cuando la entidad no es persistida en la base de datos,
        // que es lo que hace el método UploadedFile move()
        $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;
    }
}

Se puede comprobar que ahora hay que hacer algo más para borrar el archivo. Antes de eliminarlo, debes guardar el file path (ya que depende del id). Una vez que el objeto se ha eliminado de la base de datos, puedes borrarlo de forma segura (en PostRemove).

Fuentes: symfony.com