Handling an Upload
Receive a file with UploadedFile, validate its real content and size, rename it yourself, and store it outside the web root.
#[MapUploadedFile(new Assert\File(maxSize: '8M', extensions: ['pdf']))] UploadedFile $documentA form sent with enctype="multipart/form-data" puts the file bytes in the request body. PHP writes each file to a temporary path and deletes it when the request ends. Symfony wraps each one in an UploadedFile object, reachable through $request->files. If you do not copy the file somewhere permanent during that request, it is gone.
Validate the content, not the name
The filename the browser sends is attacker-controlled, so getClientOriginalName() and getClientOriginalExtension() are untrusted strings. A constraint is a small rule object, and the validator checks a value against it. The Assert\File constraint's extensions option checks that the extension and the detected media type agree, which is why the documentation prefers it to mimeTypes. maxSize accepts '8M' or '1024k'; filenameMaxLength caps the name length.
Rename it, then move it
guessExtension() derives the extension from the detected media type rather than from the sent name. Slug the original stem, add random bytes, and you have a name that cannot collide or traverse directories. move($directory, $name) then writes it. Keep that directory outside public/: anything under the web root is served as a URL by the web server, with no code of yours involved.
Shorthand and longhand
#[MapUploadedFile] on a controller argument is the same work written shorter: it reads the field from $request->files, runs the constraints you pass, and returns 422 on a violation instead of entering the method.
Example
<?php
// src/Controller/DocumentUploadController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Validator\Constraints as Assert;
final class DocumentUploadController extends AbstractController
{
public function __construct(
// var/ is outside public/, so nothing here has a URL of its own.
#[Autowire('%kernel.project_dir%/var/storage/documents')]
private readonly string $storageDir,
private readonly SluggerInterface $slugger,
) {
}
#[Route('/documents', name: 'app_document_upload', methods: ['POST'])]
public function upload(
// Reads the file field named "document" and validates it
// before this method body runs.
#[MapUploadedFile([
new Assert\File(maxSize: '8M', extensions: ['pdf']),
])]
UploadedFile $document,
): JsonResponse {
$stem = pathinfo($document->getClientOriginalName(), PATHINFO_FILENAME);
// guessExtension() uses the detected media type, not the sent name.
$name = $this->slugger->slug($stem)
.'-'.bin2hex(random_bytes(8))
.'.'.$document->guessExtension();
$document->move($this->storageDir, $name);
return $this->json(['stored' => $name], 201);
}
}When to use it
- Accept a PDF invoice from a signed-in customer and keep it off the public web root.
- Reject a file whose extension and real media type disagree before anything is written to disk.
- Store several attachments from one form, each under a generated, collision-free name.
- Cap uploads at 8 MB and return a readable error instead of a 500.
More examples
The same thing written out by hand
This is what #[MapUploadedFile] does on your behalf: pull the field out of $request->files, run the constraints, answer 422 when one fails. Reach for the long form when the field name is dynamic, when several fields must be validated together, or when you want to shape the error body yourself. A POST with a 9 MB PDF returns 422 with the maxSize message; a valid one returns 201.
<?php
// src/Controller/ManualUploadController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ManualUploadController extends AbstractController
{
public function __construct(
private readonly ValidatorInterface $validator,
) {
}
#[Route('/documents/manual', name: 'app_document_manual', methods: ['POST'])]
public function upload(Request $request): JsonResponse
{
// files, not request: $request->request is the POST fields,
// $request->query is the query string.
$document = $request->files->get('document');
$violations = $this->validator->validate($document, [
new Assert\NotNull(),
new Assert\File(maxSize: '8M', extensions: ['pdf']),
]);
if (count($violations) > 0) {
$messages = [];
foreach ($violations as $violation) {
$messages[] = $violation->getMessage();
}
return $this->json(['errors' => $messages], 422);
}
return $this->json(['size' => $document->getSize()], 201);
}
}An upload field inside a form
Use this when the upload is one field among several and you want the violations rendered next to the input. The controller reads the files with $form->get('files')->getData(), which returns an array of UploadedFile objects, and stores only the generated filenames on the entity. mapped: false matters because the property holds a filename string, not a file.
<?php
// src/Form/DocumentType.php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
final class DocumentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class)
->add('files', FileType::class, [
'label' => 'Documents (PDF)',
// mapped: false keeps the form from writing the
// UploadedFile objects onto the data class.
'mapped' => false,
'multiple' => true,
'required' => false,
'constraints' => [
// With multiple: true the value is an array, so each
// item is validated through All.
new Assert\All([
new Assert\File(
maxSize: '8M',
extensions: ['pdf'],
extensionsMessage: 'Upload a PDF document.',
),
]),
],
]);
}
}Binding the storage directory once
A bind under _defaults replaces the per-class #[Autowire] attribute when more than one service needs the same path. Keeping the directory in a parameter also lets the test environment point it at a scratch directory, so tests never write into the real storage tree.
# config/services.yaml
parameters:
app.document_dir: '%kernel.project_dir%/var/storage/documents'
services:
_defaults:
autowire: true
autoconfigure: true
# Every service argument named $storageDir of type string
# receives this value, so the path is written once.
bind:
string $storageDir: '%app.document_dir%'
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Kernel.php'
Discussion