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.
- 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)
- AbstractImageFile (extends AbstractFile)
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 '';
}
}
The file is handled by a Doctrine Entity Listener. See:
- https://www.doctrine-project.org/projects/doctrine-bundle/en/latest/entity-listeners.html
- https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#entity-listeners-class
- https://symfony.com/doc/current/doctrine/events.html#doctrine-entity-listeners
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;
}
}
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 enforceLogo
validation constraints (constraints of embeded FormTypes are not enforced by default). - Add
cascade={"persist"}
orLogo
won't be persisted alongsideCompany
. - 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);
});
});
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);
});
});
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;
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;
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
).