Skip to content

Instantly share code, notes, and snippets.

@thibaut-decherit
Last active December 14, 2023 16:16
Show Gist options
  • Save thibaut-decherit/4cf2433c81ebd1cd2ed91976173cc04e to your computer and use it in GitHub Desktop.
Save thibaut-decherit/4cf2433c81ebd1cd2ed91976173cc04e to your computer and use it in GitHub Desktop.
Symfony - File Entity and File Upload

Symfony - File Entity and File Upload

Note: The file's absolute path is NOT stored in database to prevent directory traversal in case an attacker manages to modify said path through SQL injection.

Inheritance schema

  • AbstractFile
    • AbstractImageFile (extends AbstractFile)
      • ProfilePicture (extends AbstractImageFile)
      • ProjectPicture (extends AbstractImageFile)
    • AbstractVideoFile (extends AbstractFile)
      • VlogEpisode (extends AbstractVideoFile)
    • AbstractPdfFile (extends AbstractFile)
      • FieldReport (extends AbstractPdfFile)
      • Invoice (extends AbstractPdfFile)

AbstractFile Class

src/Model/AbstractFile.php This abstract class provides properties common to all abstract classes requiring file upload and file metadata support.

<?php

namespace App\Model;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;

/**
 * Class AbstractFile
 * @package App\Model
 *
 * Abstract class for classes and entities requiring file upload and file metadata support.
 */
abstract class AbstractFile
{
    /**
     * @var int|null
     *
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected ?int $id = null;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string", length=255)
     */
    protected ?string $originalName = null;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string", length=255)
     */
    protected ?string $extension = null;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string", length=255)
     */
    protected ?string $mimeType = null;

    /**
     * File size in bytes.
     *
     * @var int|null
     *
     * Choose column type according to your use case:
     *   - smallint: 32.76 KB max (rounded down)
     *   - integer: 2.14 GB max (rounded down)
     *   - bigint: 9.22 EB max (rounded down)
     * @ORM\Column(type="integer")
     */
    protected ?int $size = null;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string", length=255, unique=true)
     */
    protected ?string $name = null;

    /**
     * Determines if file will be uploaded in /public directory or in a directory outside /public and therefore
     * reachable only through a controller (probably after some sort of authentication or validation).
     *
     * @var boolean
     *
     * @ORM\Column(type="boolean")
     */
    protected bool $private = false;

    /**
     * @var File|null
     */
    protected ?File $file = null;

    /**
     * @ORM\Column(type="string", length=255)
     */
    protected ?string $hash = null;

    /**
     * @ORM\Column(type="datetime")
     */
    protected DateTime $createdAt;

    /**
     * @ORM\Column(type="datetime")
     */
    protected ?DateTime $modifiedAt = null;

    /**
     * AbstractFile constructor
     *
     * @param bool $private
     */
    public function __construct(bool $private = false)
    {
        $this->private = $private;
        $this->createdAt = new DateTime();
    }

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param string|null $originalName
     * @return $this
     */
    public function setOriginalName(?string $originalName): self
    {
        $this->originalName = $originalName;

        return $this;
    }

    /**
     * Returns original name WITHOUT extension.
     *
     * @return string|null
     */
    public function getOriginalName(): ?string
    {
        return $this->originalName;
    }

    /**
     * @param string|null $extension
     * @return $this
     */
    public function setExtension(?string $extension): self
    {
        $this->extension = $extension;

        return $this;
    }

    /**
     * @return string|null
     */
    public function getExtension(): ?string
    {
        return $this->extension;
    }

    /**
     * @param string|null $mimeType
     * @return $this
     */
    public function setMimeType(?string $mimeType): self
    {
        $this->mimeType = $mimeType;

        return $this;
    }

    /**
     * @return string|null
     */
    public function getMimeType(): ?string
    {
        return $this->mimeType;
    }

    /**
     * @param int|null $size
     * @return $this
     */
    public function setSize(?int $size): self
    {
        $this->size = $size;

        return $this;
    }

    /**
     * @return int|null
     */
    public function getSize(): ?int
    {
        return $this->size;
    }

    /**
     * @param string|null $name
     * @return $this
     */
    public function setName(?string $name): self
    {
        $this->name = $name;

        return $this;
    }

    /**
     * Returns random name WITHOUT extension, this is the name of the actual file stored on the server.
     *
     * @return string|null
     */
    public function getName(): ?string
    {
        return $this->name;
    }

    /**
     * @param bool $private
     * @return $this
     */
    public function setPrivate(bool $private): self
    {
        $this->private = $private;

        return $this;
    }

    /**
     * @return bool
     */
    public function isPrivate(): bool
    {
        return $this->private;
    }

    /**
     * @param File|null $file
     * @return $this
     */
    public function setFile(?File $file): self
    {
        $this->file = $file;

        /*
         * We set $this->modifiedAt here to trigger src/EventListener/FileHandlingDoctrineEntityListener.php @ORM\PostUpdate
         * if nothing else in the entity has changed. Indeed, $this->file is not tracked by doctrine so if it is
         * the only property to change (e.g. edit form where only the file input has been modified) @ORM\PostUpdate
         * will not trigger so the file will not be persisted to disk and the entry in database will not be updated.
         */
        if (!is_null($file)) {
            if (is_null($this->getModifiedAt())) {
                /*
                 * If $this->modifiedAt is null it means $this just got created, so we retrieve $this->createdAt to make
                 * sure both have the same value in case more than one second passed between creating $this and calling
                 * $this->setFile().
                 */
                $this->setModifiedAt($this->getCreatedAt());
            } else {
                $this->setModifiedAt(new DateTime());
            }
        }

        return $this;
    }

    /**
     * @return File|null
     */
    public function getFile(): ?File
    {
        return $this->file;
    }

    /**
     * @return string|null
     */
    public function getHash(): ?string
    {
        return $this->hash;
    }

    /**
     * @param string $hash
     * @return $this
     */
    public function setHash(string $hash): self
    {
        $this->hash = $hash;

        return $this;
    }

    /**
     * @return DateTime
     */
    public function getCreatedAt(): DateTime
    {
        return $this->createdAt;
    }

    /**
     * @param DateTime $createdAt
     * @return $this
     */
    public function setCreatedAt(DateTime $createdAt): self
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    /**
     * @return DateTime|null
     */
    public function getModifiedAt(): ?DateTime
    {
        return $this->modifiedAt;
    }

    /**
     * @param DateTime $modifiedAt
     * @return $this
     */
    public function setModifiedAt(DateTime $modifiedAt): self
    {
        $this->modifiedAt = $modifiedAt;

        return $this;
    }

    /**
     * @return string|null
     */
    public function getOriginalNameWithExtension(): ?string
    {
        if (is_null($this->getOriginalName()) && is_null($this->getExtension())) {
            return null;
        }

        return $this->getOriginalName() . '.' . $this->getExtension();
    }

    /**
     * @return string
     */
    public function getUploadDir(): string
    {
        return '';
    }
}

Handling the file itself

The file is handled by a Doctrine Entity Listener. See:

config/services.yaml

parameters:
  # WARNING: If you change these in production you have to move existing files to their new directory.
  app.file_upload_private_directory: 'private-uploads'
  app.file_upload_public_directory: 'uploads'

services:
  App\EventListener\FileHandlingDoctrineEntityListener:
    arguments:
      $kernelProjectDir: '%kernel.project_dir%'
      $fileUploadPrivateDirectory: '%app.file_upload_private_directory%'
      $fileUploadPublicDirectory: '%app.file_upload_public_directory%'
    tags:
      - { name: doctrine.orm.entity_listener, lazy: true }
  # [...]

  App\Service\UploadedFilePathService:
    arguments:
      $kernelProjectDir: '%kernel.project_dir%'
      $fileUploadPrivateDirectory: '%app.file_upload_private_directory%'
      $fileUploadPublicDirectory: '%app.file_upload_public_directory%'

src/EventListener/FileHandlingDoctrineEntityListener.php

<?php

namespace App\EventListener;

use App\Helper\RandomDataGeneratorHelper;
use App\Helper\SanitizationHelper;
use App\Model\AbstractFile;
use App\Service\UploadedFilePathService;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * Class FileHandlingDoctrineEntityListener
 *
 * Handles upload and deletion of the file attached to an entity with a $file property when this entity is persisted,
 * updated or removed.
 *
 * @package App\EventListener
 */
class FileHandlingDoctrineEntityListener
{
    /**
     * @var string
     */
    private string $kernelProjectDir;

    /**
     * @var string
     */
    private string $fileUploadPrivateDirectory;

    /**
     * @var string
     */
    private string $fileUploadPublicDirectory;

    /**
     * @var UploadedFilePathService
     */
    private UploadedFilePathService $uploadedFilePathService;

    /**
     * FileHandlingDoctrineEntityListener constructor
     *
     * @param string $kernelProjectDir
     * @param string $fileUploadPrivateDirectory
     * @param string $fileUploadPublicDirectory
     * @param UploadedFilePathService $uploadedFilePathService
     */
    public function __construct(
        string $kernelProjectDir,
        string $fileUploadPrivateDirectory,
        string $fileUploadPublicDirectory,
        UploadedFilePathService $uploadedFilePathService
    )
    {
        $this->kernelProjectDir = $kernelProjectDir;
        $this->fileUploadPrivateDirectory = $fileUploadPrivateDirectory;
        $this->fileUploadPublicDirectory = $fileUploadPublicDirectory;
        $this->uploadedFilePathService = $uploadedFilePathService;
    }

    /**
     * Deletes previous file from disk on file update.
     *
     * @ORM\PreUpdate
     *
     * @param AbstractFile $file
     */
    public function preUpdate(AbstractFile $file): void
    {
        // "file" property can be empty during entity update.
        if (is_null($file->getFile())) {
            return;
        }

        $this->deleteFromDisk($file);
    }

    /**
     * Sets file properties (name, path...) prior to database write.
     *
     * @ORM\PrePersist
     * @ORM\PreUpdate
     *
     * @param AbstractFile $file
     * @throws Exception
     */
    public function preUpload(AbstractFile $file): void
    {
        // "file" property can be empty during entity update.
        if (is_null($file->getFile())) {
            return;
        }

        /*
         * A file attached to $file already exists on disk, which means the entity is being updated and its file is
         * being replaced by another one, so we need to remove the old file or it will remain on disk as an orphan.
         */
        if (!is_null($file->getUploadName())) {
            $this->delete($file);
        }

        $uniqueName = RandomDataGeneratorHelper::randomString(256);

        $file->setName($uniqueName);
        $file->setSize($file->getFile()->getSize());
        $file->setHash(hash_file('sha256', $file->getFile()->getPathname()));

        // Sets common metadata depending how the file has been created on the server (uploaded or not).
        if ($file->getFile() instanceof UploadedFile) {
            $file->setOriginalName(
                pathinfo(
                    SanitizationHelper::filename($file->getFile()->getClientOriginalName()),
                    PATHINFO_FILENAME
                )
            );
            $file->setExtension(
                pathinfo(
                    SanitizationHelper::filename($file->getFile()->getClientOriginalName()),
                    PATHINFO_EXTENSION
                )
            );
            $file->setMimeType($file->getFile()->getClientMimeType());
        } else {
            $file->setOriginalName($uniqueName);
            $file->setExtension($file->getFile()->getExtension());
            $file->setMimeType($file->getFile()->getMimeType());
        }
    }

    /**
     * Moves file to upload directory after database write.
     *
     * @ORM\PostPersist
     * @ORM\PostUpdate
     *
     * @param AbstractFile $file
     */
    public function upload(AbstractFile $file): void
    {
        // "file" property can be empty during entity update.
        if (is_null($file->getFile())) {
            return;
        }

        $absoluteUploadPath = $this->uploadedFilePathService->getAbsolutePath($file, false);

        if (!file_exists($absoluteUploadPath)) {
            mkdir($absoluteUploadPath, 0750, true);
        }

        $file->getFile()->move(
            $absoluteUploadPath,
            $file->getName()
        );

        // Clears the file from the entity now that it has been moved to disk.
        $file->setFile(null);
    }

    /**
     * Deletes file after entity has been removed from database.
     *
     * @ORM\PostRemove
     *
     * @param AbstractFile $file
     */
    public function deleteFromDisk(AbstractFile $file): void
    {
        unlink($this->uploadedFilePathService->getAbsolutePath($file));
    }
}

src/Service/UploadedFilePathService.php

<?php

namespace App\Service;

use App\Helper\SanitizationHelper;
use App\Model\AbstractFile;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

/**
 * Class UploadedFilePathService
 *
 * Returns the absolute path of an uploaded file extending App\Model\AbstractFile.
 *
 * @package App\Service
 */
class UploadedFilePathService
{
    /**
     * @var string
     */
    private string $kernelProjectDir;

    /**
     * @var string
     */
    private string $fileUploadPrivateDirectory;

    /**
     * @var string
     */
    private string $fileUploadPublicDirectory;

    /**
     * UploadedFilePathService constructor
     *
     * @param string $kernelProjectDir
     * @param string $fileUploadPrivateDirectory
     * @param string $fileUploadPublicDirectory
     */
    public function __construct(
        string $kernelProjectDir,
        string $fileUploadPrivateDirectory,
        string $fileUploadPublicDirectory
    )
    {
        $this->kernelProjectDir = $kernelProjectDir;
        $this->fileUploadPrivateDirectory = $fileUploadPrivateDirectory;
        $this->fileUploadPublicDirectory = $fileUploadPublicDirectory;
    }

    /**
     * @param AbstractFile $file
     * @param bool $includeFilename
     * @return string
     */
    public function getAbsolutePath(AbstractFile $file, bool $includeFilename = true): string
    {
        $uploadDir = $this->kernelProjectDir;

        if ($file->isPrivate()) {
            $uploadDir .= '/' . $this->fileUploadPrivateDirectory;
        } else {
            $uploadDir .= '/public/' . $this->fileUploadPublicDirectory;
        }

        $uploadDir .= $file->getUploadDir();

        $filePath = $uploadDir;
        
        if ($includeFilename) {
            $filePath = $uploadDir . '/' . $file->getName();
        }

        if (SanitizationHelper::hasDirectoryTraversal($filePath)) {
            throw new AccessDeniedException('Access Denied.');
        }

        return $filePath;
    }
}

Implementation example for image files

src/Model/AbstractImageFile.php This abstract class inherits AbstractFile class and implements/modifies logic and properties specific to image files (e.g. @Assert\Image).

<?php

namespace App\Model;

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

/**
 * Class AbstractImageFile
 * @package App\Model
 *
 * Abstract class for entities requiring image upload and image metadata support.
 */
abstract class AbstractImageFile extends AbstractFile
{
    const PATH_FRAGMENT = 'images';

    /**
     * Image width in pixels.
     *
     * @var int|null
     * @ORM\Column(type="smallint")
     */
    protected ?int $width = null;

    /**
     * Image height in pixels.
     *
     * @var int|null
     * @ORM\Column(type="smallint")
     */
    protected ?int $height = null;

    /**
     * @var File|null
     *
     * @Assert\Image(
     *     maxSize="2M",
     *     mimeTypes={"image/jpeg", "image/png"},
     *     mimeTypesMessage="form_errors.global.mime_types_image"
     * )
     */
    protected ?File $file = null;

    /**
     * @return string
     */
    public function getUploadDir(): string
    {
        return parent::getUploadDir() . '/' . self::PATH_FRAGMENT;
    }

    /**
     * @param int|null $width
     * @return $this
     */
    public function setWidth(?int $width): self
    {
        $this->width = $width;

        return $this;
    }

    /**
     * @return int|null
     */
    public function getWidth(): ?int
    {
        return $this->width;
    }

    /**
     * @param int|null $height
     * @return $this
     */
    public function setHeight(?int $height): self
    {
        $this->height = $height;

        return $this;
    }

    /**
     * @return int|null
     */
    public function getHeight(): ?int
    {
        return $this->height;
    }
}

validators.en.yaml

# Form errors

form_errors:
  global:
    mime_types_image: The file format must be jpeg or png.

src/EventListener/FileHandlingDoctrineEntityListener.php

// [...]
public function preUpload(AbstractFile $file): void
{
    // [...]
    if ($file instanceof AbstractImageFile) {
            list($width, $height) = getimagesize($file->getFile());

            $file->setWidth($width);
            $file->setHeight($height);
        }
}

src/Entity/Logo.php This is an example of "final" implementation inheriting previous classes.

<?php

namespace App\Entity;

use App\Model\AbstractImageFile;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * Class Logo
 * @package App\Entity
 * @ORM\Entity(repositoryClass="App\Repository\LogoRepository")
 * @UniqueEntity(
 *     fields={"name"}
 * )
 * @ORM\EntityListeners({"App\EventListener\FileHandlingDoctrineEntityListener"})
 */
class Logo extends AbstractImageFile
{
    const PATH_FRAGMENT = 'logo';

    /**
     * Logo constructor
     */
    public function __construct()
    {
        parent::__construct(true);
    }

    /**
     * @return string
     */
    public function getUploadDir(): string
    {
        return parent::getUploadDir() . '/' . self::PATH_FRAGMENT;
    }
}

src/Controller/Logo/LogoController.php You only need to add the following code to your controller method to handle the picture file upload and picture entity persist.

$logoForm = $this->createForm(LogoType::class, $logo);
$logoForm->handleRequest($request);

if ($logoForm->isSubmitted() && $logoForm->isValid()) {
    $em = $this->getDoctrine()->getManager();
    $em->persist($logo);
    $em->flush();
}

Now let's assume you want to add a logo to a parent Company entity with a ManyToOne relationship.

src/Entity/Company

  • Add @Assert\Valid to the $logo property or Symfony won't enforce Logo validation constraints (constraints of embeded FormTypes are not enforced by default).
  • Add cascade={"persist"} or Logo won't be persisted alongside Company.
  • You may want to add orphanRemoval=true too.
  • Add fetch="EAGER" or some features (e.g. orphanRemoval) will not work.

src/Form/LogoType.php

<?php

namespace App\Form\Logo;

use App\Entity\Logo;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class LogoType
 * @package App\Form\Logo
 */
class LogoType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('file', FileType::class, [
                'label' => 'Add a picture',
                'required' => true
            ]);
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Logo::class
        ]);
    }

    /**
     * @return string
     */
    public function getBlockPrefix(): string
    {
        return 'App_logo';
    }
}

assets/js/views/logo/new.js

import $ from 'jquery';
import {body} from '../../components/helpers/jquery/selectors';

body.on('submit', '#id-of-the-form', function (e) {
    const form = $(this);

    // Prevents submit button default behaviour
    e.preventDefault();

    $.ajax({
        type: $(this).attr('method'),
        url: $(this).attr('action'),
        data: new FormData(this),
        contentType: false,
        processData: false
    })
        // Triggered if response status == 200 (form is valid and data has been processed successfully)
        .done(function (response) {
            // Parses the JSON response to "unescape" the html code within
            const template = JSON.parse(response.template);

            form.replaceWith(template);
        })
        // Triggered if response status == 400 (form has errors)
        .fail(function (response) {
            // Parses the JSON response to "unescape" the html code within
            const template = JSON.parse(response.responseJSON.template);
            //  Replaces html content of html element id 'ajax-form-logo' with updated form
            // (with errors and input values)
            form.replaceWith(template);
        });
});

Warning

contentType: false,
processData: false

Could have undesired side-effects. For example processData: false seems to break the submission of array inputs (such as <select multiple="multiple">) because they are no longer serialized.

A possible workaround is to use axios instead of jQuery AJAX, because axios is able to handle files and arrays simultaneously without any additional configuration.

assets/js/views/logo/new.js

import $ from 'jquery';
import axios from 'axios';
import {body} from '../../components/helpers/jquery/selectors';

body.on('submit', '#id-of-the-form', function (e) {
    const form = $(this);

    // Prevents submit button default behaviour
    e.preventDefault();

    axios
        .post($(this).attr('action'), new FormData(this))
        .then(response => {
            // Parses the JSON response to "unescape" the html code within
            const template = JSON.parse(response.template);

            form.replaceWith(template);
        })
        .catch(error => {
            const response = error.response.data;
            // Parses the JSON response to "unescape" the html code within
            const template = JSON.parse(response.template);
            //  Replaces html content of html element id 'ajax-form-logo' with updated form
            // (with errors and input values)
            form.replaceWith(template);
        });
});

Note about file entity related to another entity with orphanRemoval

Let's say the logo as a OneToOne relationship with a Company entity like so:

/**
 * @ORM\OneToOne(targetEntity="App\Entity\Logo", cascade={"persist"}, orphanRemoval=true)
 */
private $logo;

If you remove a Company the Logo file will NOT be deleted from the disk because the PostRemove event as been "indirectly" triggered by the removal of Company, so $company->getLogo() is a proxy lazy loaded by Doctrine, meaning all its properties are null, including $path, so unlink($file->getPath()); will not work (it will not crash though, so Logo will be removed from the database but the now "orphan" file will still be on the disk.

To fix this you can tell Doctrine to always retrieve all the data of Logo when its parent Company is loaded by adding the fetch="EAGER" parameter to the relation side with orphanRemoval=true:

/**
 * @ORM\OneToOne(targetEntity="App\Entity\Logo", cascade={"persist"}, orphanRemoval=true, fetch="EAGER")
 */
private $logo;

Note about file FormType embedded in the form of another entity

Validation

Let's say the logo is uploaded through the form of a Company entity.

You must add @Assert\Valid to the $logo property or constraints in Logo class and its parents classes will not be enforced. src/Entity/Company.php

/**
 * @ORM\OneToOne(targetEntity="App\Entity\Logo", cascade={"persist", "remove"}, orphanRemoval=true)
 * @ORM\JoinColumn(nullable=true)
 * @Assert\Valid
 */
private $logo;

[SOLVED] Validation bug (php.ini upload_max_filesize)

Furthermore, at this time there is a bug displaying two error messages instead of one for embedded file FormType classes, requiring a workaround to hide the duplicate message. See this issue for informations about the bug: symfony/symfony#36503

Here is a possible workaround:

Add uploadIniSizeErrorMessage="" to AbstractImageFile. src/Model/AbstractImageFile.php

/**
 * @var File|null
 *
 * @Assert\Image(
 *     maxSize="2M",
 *     mimeTypes={"image/jpeg", "image/png"},
 *     uploadIniSizeErrorMessage=""
 * )
 */
protected $file;

Then hide the empty error with JavaScript: assets/js/views/company/new.js

import {removeFileInputEmptyErrorMessages} from '../../components/helpers/FileInputHelper';

// [...]

        .catch(error => {
            const response = error.response.data;
            // Parses the JSON response to "unescape" the html code within
            const template = $(JSON.parse(response.template));

            removeFileInputEmptyErrorMessages(template);

            //  Replaces html content of html element id 'ajax-form-logo' with updated form
            // (with errors and input values)
            form.replaceWith(template);
        });

Do note that with this workaround you cannot customize uploadIniSizeErrorMessage for the embedded entity (here Logo).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment