Mid💻Практика4 min

Загрузка файлов

UploadedFile, file validation, storage, BinaryFileResponse

Загрузка файлов

Обработка загрузки файлов — практическая тема экзамена Symfony. Нужно знать класс UploadedFile, валидацию файлов, безопасное хранение и отправку файлов клиенту.

Получение загруженного файла

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;

class AvatarController extends AbstractController
{
    #[Route('/profile/avatar', name: 'avatar_upload', methods: ['POST'])]
    public function upload(Request $request): Response
    {
        /** @var UploadedFile|null $file */
        $file = $request->files->get('avatar');

        if ($file === null) {
            throw new BadRequestHttpException('No file uploaded');
        }

        // Check upload was successful
        if (!$file->isValid()) {
            throw new BadRequestHttpException(
                'Upload error: ' . $file->getErrorMessage()
            );
        }

        return $this->json(['status' => 'ok']);
    }
}

UploadedFile Methods

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\File\UploadedFile;

/** @var UploadedFile $file */

// File information
$file->getClientOriginalName();    // 'photo.jpg' (user-provided, UNTRUSTED!)
$file->getClientOriginalExtension(); // 'jpg' (from original name, UNTRUSTED!)
$file->getClientMimeType();         // 'image/jpeg' (from browser, UNTRUSTED!)
$file->guessExtension();            // 'jpg' (from file content, TRUSTED)
$file->getMimeType();               // 'image/jpeg' (from file content, TRUSTED)
$file->getSize();                    // 102400 (bytes)
$file->getError();                   // UPLOAD_ERR_OK (0)
$file->getErrorMessage();           // Human-readable error
$file->isValid();                    // true if UPLOAD_ERR_OK

// Temporary file path
$file->getPathname();               // '/tmp/phpXyz123'
$file->getRealPath();               // Resolved real path

// Move file to permanent location
$file->move(
    '/path/to/upload/dir',           // Target directory
    'new-filename.jpg'               // New filename (optional)
);

Подвох экзамена: getClientOriginalName(), getClientOriginalExtension() и getClientMimeType() — данные ОТ ПОЛЬЗОВАТЕЛЯ. Их НЕЛЬЗЯ доверять! Для определения реального типа файла используйте getMimeType() и guessExtension(), которые анализируют содержимое файла. На экзамене обязательно спрашивают разницу.

Таблица trusted vs untrusted

Метод Источник Доверять?
getClientOriginalName() Браузер Нет
getClientOriginalExtension() Из имени файла Нет
getClientMimeType() Браузер Нет
getMimeType() Анализ содержимого Да
guessExtension() Анализ содержимого Да
getSize() PHP Да

Безопасная загрузка

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;

class FileUploader
{
    public function __construct(
        private readonly string $uploadDirectory,
        private readonly SluggerInterface $slugger,
    ) {
    }

    public function upload(UploadedFile $file): string
    {
        // Generate safe, unique filename
        $originalFilename = pathinfo(
            $file->getClientOriginalName(),
            PATHINFO_FILENAME
        );
        $safeFilename = $this->slugger->slug($originalFilename);
        $newFilename = $safeFilename . '-' . uniqid() . '.' . $file->guessExtension();

        try {
            $file->move($this->uploadDirectory, $newFilename);
        } catch (FileException $e) {
            throw new \RuntimeException('Failed to upload file: ' . $e->getMessage());
        }

        return $newFilename;
    }
}

Конфигурация сервиса

# config/services.yaml
parameters:
    upload_directory: '%kernel.project_dir%/public/uploads'

services:
    App\Service\FileUploader:
        arguments:
            $uploadDirectory: '%upload_directory%'

Валидация файлов

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

class UserProfile
{
    #[Assert\File(
        maxSize: '5M',
        mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
        mimeTypesMessage: 'Пожалуйста, загрузите изображение (JPEG, PNG, WebP)',
    )]
    private ?UploadedFile $avatar = null;

    // Or use Image constraint for images specifically
    #[Assert\Image(
        maxSize: '5M',
        minWidth: 100,
        maxWidth: 4000,
        minHeight: 100,
        maxHeight: 4000,
        allowSquare: true,
        allowLandscape: true,
        allowPortrait: true,
        mimeTypes: ['image/jpeg', 'image/png'],
    )]
    private ?UploadedFile $photo = null;
}

File vs Image constraints

Constraint Проверяет
#[Assert\File] Размер, MIME-тип, расширение
#[Assert\Image] Всё из File + размеры изображения, ratio, формат

Загрузка в формах

<?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\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\File;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name')
            ->add('image', FileType::class, [
                'label' => 'Product Image',
                'mapped' => false,  // Not mapped to entity property
                'required' => false,
                'constraints' => [
                    new File([
                        'maxSize' => '2M',
                        'mimeTypes' => ['image/jpeg', 'image/png'],
                        'mimeTypesMessage' => 'Please upload a valid image',
                    ]),
                ],
            ]);
    }
}
<?php

declare(strict_types=1);

// Controller handling file upload form
class ProductController extends AbstractController
{
    #[Route('/products/new', name: 'product_new', methods: ['GET', 'POST'])]
    public function new(
        Request $request,
        FileUploader $uploader,
    ): Response {
        $form = $this->createForm(ProductType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            /** @var UploadedFile|null $imageFile */
            $imageFile = $form->get('image')->getData();

            if ($imageFile) {
                $filename = $uploader->upload($imageFile);
                $product->setImageFilename($filename);
            }

            // Save product...
            $this->addFlash('success', 'Product created!');
            return $this->redirectToRoute('product_list');
        }

        return $this->render('product/new.html.twig', [
            'form' => $form,
        ]);
    }
}

Подвох экзамена: При использовании mapped: false в FileType, файл НЕ автоматически привязывается к свойству entity. Нужно вручную вызвать $form->get('image')->getData() для получения UploadedFile. Если mapped: true — Symfony попытается установить UploadedFile в свойство entity.

Отправка файлов клиенту

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

class DownloadController extends AbstractController
{
    // Method 1: file() helper from AbstractController
    #[Route('/download/{filename}', name: 'file_download')]
    public function download(string $filename): BinaryFileResponse
    {
        $filePath = $this->getParameter('upload_directory') . '/' . $filename;

        // Inline — display in browser
        return $this->file($filePath);

        // Download — force save dialog
        return $this->file($filePath, 'custom-name.pdf', ResponseHeaderBag::DISPOSITION_ATTACHMENT);
    }

    // Method 2: BinaryFileResponse directly
    #[Route('/report/{id}', name: 'report_download')]
    public function report(int $id): BinaryFileResponse
    {
        $filePath = '/path/to/report.pdf';

        $response = new BinaryFileResponse($filePath);
        $response->setContentDisposition(
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
            'report-' . $id . '.pdf'
        );

        // Auto-detect Content-Type
        $response->headers->set('Content-Type', 'application/pdf');

        // Delete file after sending (temporary files)
        $response->deleteFileAfterSend(true);

        return $response;
    }
}

PHP Upload Limits

; php.ini settings that affect file uploads
upload_max_filesize = 10M     ; Max size per file
post_max_size = 12M           ; Max size of entire POST body (> upload_max_filesize!)
max_file_uploads = 20         ; Max number of files per request
file_uploads = On             ; Enable file uploads

Подвох экзамена: post_max_size должен быть БОЛЬШЕ upload_max_filesize. Если post_max_size меньше — загрузка молча провалится и $_FILES будет пустым. Symfony не может обнаружить эту ситуацию — файл просто будет null в Request.