Documenting and Testing File Upload Endpoints in Symfony
AI generated
{ }
GET
REST API · Symfony · OpenAPI · Testing
Documenting and Testing File Upload Endpoints in Symfony
from multipart/form-data to full test coverage

Upload endpoints are a common source of errors in REST APIs. Incorrectly documented content types, missing validation and incomplete tests turn them into a black box. This guide shows how to correctly describe Symfony upload endpoints with OpenAPI 3.1, secure them on the server side and test them fully with PHPUnit.

15 min read multipart/form-data · NelmioApiDocBundle · PHPUnit · Validation Symfony 7.x · OpenAPI 3.1 · PHP 8.4

1. Why upload endpoints require special care

File upload endpoints differ fundamentally from JSON-based REST endpoints: they use multipart/form-data instead of application/json, the framework routing must be configured specifically, and validation must go beyond a plain schema check. In practice, errors arise from an incorrectly set Content-Type header, missing size limits in PHP and Nginx, and incomplete OpenAPI descriptions that mislead integrators.

Another critical point is security: upload endpoints are a prime target for uploading malicious code disguised as an image or PDF. MIME type checking must not rely on the Content-Type sent by the client, but must inspect the actual file magic via finfo. Anyone who additionally validates the file extension, limits the file size and stores uploads in a non-public directory closes off the most common attack vectors.

2. The Symfony controller: UploadedFile and validation

In Symfony, the class Symfony\Component\HttpFoundation\File\UploadedFile represents uploaded files from the $request->files bag. It encapsulates the original name, MIME type, file size and temporary storage path. The controller should stay as thin as possible: the actual logic, validation, renaming, storage, belongs in a dedicated service that the controller receives through dependency injection. This makes testing much easier, because the service can be tested independently of the HTTP layer.

A common mistake in Symfony upload controllers: the developer checks $file->isValid() but assumes this covers all errors. In reality, isValid() only checks whether the PHP upload completed without an error code. File type, size and content validity must be validated separately afterward. Symfony validator constraints such as File with maxSize, mimeTypes and mimeTypesMessage cleanly cover these cases and integrate seamlessly into the validation layer.


# src/Controller/Api/DocumentUploadController.php
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Service\DocumentUploadService;
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\Validator\ValidatorInterface;

#[Route('/api/v1/documents', name: 'api_documents_')]
final class DocumentUploadController extends AbstractController
{
    public function __construct(
        private readonly DocumentUploadService $uploadService,
        private readonly ValidatorInterface $validator,
    ) {}

    #[Route('/upload', name: 'upload', methods: ['POST'])]
    public function upload(Request $request): JsonResponse
    {
        $file = $request->files->get('document');

        if ($file === null) {
            return $this->json(['error' => 'No file uploaded', 'code' => 'MISSING_FILE'], 422);
        }

        $result = $this->uploadService->process($file, $request->request->all());

        return $this->json($result, 201);
    }
}

3. Defining the OpenAPI schema for multipart/form-data correctly

In OpenAPI 3.1, a file upload endpoint is described with requestBody, content and the media type multipart/form-data. The schema object defines the fields of the form: regular text fields as string and the file itself as type: string, format: binary. This binary hint signals to OpenAPI tools and client generators that this field does not contain a plain string but binary data, and that it is transmitted as a file part in the multipart body.

For the download case, when the API returns a file instead of JSON, the response description likewise uses content: application/octet-stream or the specific MIME type (e.g. application/pdf) with schema: { type: string, format: binary }. The encoding property in requestBody makes it possible to override the Content-Type for individual fields, for example when part of the multipart body should itself be JSON. This is an advanced OpenAPI feature that cleanly models the upload workflow for complex upload endpoints (a file plus structured metadata).


# openapi/paths/documents-upload.yaml
/api/v1/documents/upload:
  post:
    operationId: uploadDocument
    summary: Upload a document file with metadata
    tags:
      - Documents
    requestBody:
      required: true
      content:
        multipart/form-data:
          schema:
            type: object
            required:
              - document
              - category
            properties:
              document:
                type: string
                format: binary
                description: The document file (PDF, max 10 MB)
              category:
                type: string
                enum: [invoice, contract, report]
                description: Document category for classification
              description:
                type: string
                maxLength: 500
                description: Optional description text
          encoding:
            document:
              contentType: application/pdf, image/jpeg, image/png
    responses:
      '201':
        description: Document uploaded successfully
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DocumentUploadResponse'
      '422':
        $ref: '#/components/responses/ValidationError'

4. NelmioApiDocBundle annotations in practice

NelmioApiDocBundle allows OpenAPI documentation to be maintained directly in the Symfony controller through PHP attributes. The #[OA\RequestBody] attribute with #[OA\MediaType] for multipart/form-data describes the upload endpoint precisely. The advantage over separate YAML files: the documentation stays close to the code and is more easily kept up to date during refactoring. The downside: for very complex schemas, the attributes become unwieldy and long. A hybrid strategy is recommended here: annotate simple endpoints directly in the controller and move complex schemas out into external YAML references.

A common stumbling block with NelmioApiDocBundle and upload endpoints: if the controller uses the UploadedFile type as a parameter hint, the bundle tries to generate it as a schema, which leads to incorrect documentation. The solution is to explicitly define the upload parameter via the #[OA\Property(type: 'string', format: 'binary')] attribute and to override automatic schema generation for that parameter. With nelmio_api_doc.areas, upload endpoints can be split into separate API areas, which is useful for internal versus external documentation.

5. Server-side file type, size and content validation

Correctly validating upload files consists of several layers. Symfony's File constraint handles the first layer: the maximum file size and allowed MIME types are configured declaratively as a constraint attribute. This validation is necessary but not sufficient, because the MIME type comes from the file metadata and can be manipulated. The second layer is PHP's own finfo_open(FILEINFO_MIME_TYPE) function, which determines the actual MIME type based on the file's magic bytes, independent of the sent Content-Type header.

For PDF uploads, a third layer of content validation is recommended: the PDF must begin with the %PDF- magic byte and must not contain any JavaScript actions. For image uploads, getimagesize() can ensure that the file is actually a valid image. The file should always be stored outside the publicly reachable public/ directory, with a random file name (UUID or hash) unrelated to the original name. The original name is stored separately in the database and used for the download header when needed.


# src/Service/DocumentUploadService.php
<?php
declare(strict_types=1);

namespace App\Service;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class DocumentUploadService
{
    private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
    private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly string $uploadDirectory,
    ) {}

    public function process(UploadedFile $file, array $metadata): array
    {
        // Layer 1: Symfony constraint validation
        $violations = $this->validator->validate($file, [
            new Assert\File(
                maxSize: '10M',
                mimeTypes: self::ALLOWED_MIME_TYPES,
                mimeTypesMessage: 'Only PDF and images are allowed.',
            ),
        ]);

        if (count($violations) > 0) {
            throw new \InvalidArgumentException((string) $violations);
        }

        // Layer 2: finfo MIME check (bypass client-supplied Content-Type)
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
        $detectedMime = $finfo->file($file->getPathname());

        if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) {
            throw new \InvalidArgumentException("Detected MIME type not allowed: $detectedMime");
        }

        // Store with random filename outside public/ directory
        $newFilename = bin2hex(random_bytes(16)) . '.' . $file->guessExtension();
        $file->move($this->uploadDirectory, $newFilename);

        return [
            'id' => $newFilename,
            'originalName' => $file->getClientOriginalName(),
            'size' => $file->getSize(),
            'mimeType' => $detectedMime,
        ];
    }
}

6. Writing PHPUnit tests for upload endpoints

PHPUnit tests for upload endpoints in Symfony use the WebTestCase class and its built-in HTTP client. A real file is instantiated in the test with the UploadedFile class, in Symfony functional tests with the third parameter test: true, which overrides the PHP upload error code. This makes it possible to test error scenarios too: oversized files, incorrect MIME types and missing required fields can all be modeled with specially prepared fixture files.

Important for reliable upload tests: the directory that uploads are saved to should be a temporary directory in test mode, cleaned up after every test. Symfony's service container parameters make this possible via the configuration file config/packages/test/services.yaml. Contract tests with Spectator or an OpenAPI validator ensure that the endpoint's response structure matches the documented OpenAPI specification, a second layer of assurance in addition to the functional tests.

7. Manual tests with curl and Insomnia

For manual testing of upload endpoints, curl is the most precise tool because it makes the exact HTTP request visible. The correct curl syntax for multipart uploads uses -F for form fields and the special @ syntax for files. With --verbose and -D -, request headers, response headers and body are all shown at once, which is invaluable for troubleshooting. The most common problem in manual tests: curl automatically sets Content-Type: multipart/form-data with the correct boundary. Overriding this header manually breaks the request.

Insomnia and Postman offer graphical interfaces for multipart uploads and can generate requests directly from an imported OpenAPI specification. This is especially valuable for QA teams without command-line experience. Both tools also show the actual HTTP body sent, which helps diagnose boundary problems or incorrectly encoded fields. Versioning a complete Insomnia collection as JSON in the repository, so the team has reproducible test cases, is a simple but effective practice.

8. Error handling and meaningful error messages

Upload endpoints often return cryptic error messages when something goes wrong. A 500 with no details when the file is too large frustrates integrators and makes debugging difficult. The right strategy: answer every error case with a specific HTTP status and a structured error object in RFC 7807 (Problem Details) format. 422 Unprocessable Entity for validation errors, 413 Payload Too Large when PHP or Nginx rejects the file size, 415 Unsupported Media Type for disallowed MIME types.

A specific problem with upload endpoints: PHP and Nginx limit upload size independently of each other. If Nginx's client_max_body_size is exceeded, PHP never sees the request at all and returns a 413 error directly from Nginx, not from the controller. If PHP's upload_max_filesize or post_max_size is exceeded, $_FILES is empty and so is $_POST. The controller must explicitly check this case: if there are no files in the request but the Content-Type is still multipart/form-data, the file was likely too large for PHP.

Error case HTTP status Error code Cause / fix
No file part 422 MISSING_FILE Required field missing in the multipart body
Wrong MIME type 415 UNSUPPORTED_MEDIA finfo check fails
File too large (PHP) 413 FILE_TOO_LARGE Increase upload_max_filesize or inform the client
File too large (Nginx) 413 NGINX_LIMIT Adjust client_max_body_size in nginx.conf
PHP upload error 500 UPLOAD_ERROR isValid() false, read out the PHP error code

Mironsoft

REST API design, Symfony development and OpenAPI documentation

Symfony APIs with clean upload logic and complete documentation?

We design and implement upload endpoints that are secure, well documented and fully tested, with OpenAPI 3.1, PHPUnit coverage and structured error handling.

API design

Model upload endpoints correctly according to REST principles and OpenAPI 3.1

Security

Implement MIME validation, size limits and secure file storage

Testing

Build a PHPUnit test suite for all upload scenarios, including error paths

10. Summary

Implementing and documenting file upload endpoints correctly in Symfony requires care on several levels at once. The controller stays thin and delegates to a service. Validation consists of at least two layers: Symfony constraints for declarative rules and finfo for MIME type checking based on the file's magic bytes. The OpenAPI documentation correctly describes multipart/form-data with format: binary for file fields and lists all error cases with HTTP status and error object structure.

PHPUnit tests cover all relevant scenarios: successful upload, missing file, wrong MIME type, oversized file. Manually, curl and Insomnia are the most precise tools because they show the actual HTTP traffic. Nginx and PHP must be configured independently with consistent upload size limits. Anyone who brings all of these aspects together builds upload endpoints that are predictable for integrators, unattractive for attackers and maintainable for their own team.

File upload endpoints, the essentials at a glance

OpenAPI documentation

multipart/form-data with format: binary for file fields. Encoding object for Content-Type per part. Document all error cases with HTTP status.

Validation layers

Symfony File constraint plus finfo MIME check plus content validation. Never trust the client-supplied Content-Type.

Secure storage

Outside public/, random file name (UUID), original file name in the database. Configure Nginx and PHP size limits consistently.

Testing

PHPUnit WebTestCase with UploadedFile fixtures. curl with -F for manual tests. OpenAPI validator for contract tests in CI.

11. FAQ: File upload endpoints in Symfony

1How do I describe a file upload in OpenAPI 3.1?
requestBody with content: multipart/form-data. The file field gets type: string, format: binary. The encoding object allows Content-Type per part.
2How do I access the file in Symfony?
$request->files->get('fieldname') returns an UploadedFile object. Check isValid() first, then validate MIME type and size separately.
3Why not trust the client MIME type?
The header can be manipulated. finfo checks the actual file magic bytes independent of the sent Content-Type header.
4Nginx rejects the upload before PHP?
client_max_body_size in nginx.conf must be at least as large as upload_max_filesize in php.ini. Otherwise Nginx returns 413 directly.
5Writing a PHPUnit test for an upload?
WebTestCase with $client->request() and UploadedFile in the files array. Third parameter true simulates a successful PHP upload.
6curl for upload tests?
curl -X POST -F 'document=@file.pdf' -F 'category=invoice' https://api.example.com/upload. @ sends the file as binary data.
7Where to store files?
Outside public/, random file name. Original name only in the database and in the Content-Disposition header during download.
8HTTP status for upload validation errors?
422 for constraint errors, 413 for size limits, 415 for disallowed MIME types. Always return a structured error object.
9Describing a download endpoint in OpenAPI?
Response with content: application/pdf and schema: {type: string, format: binary}. Document Content-Disposition in the response's headers object.
10Multipart part with JSON?
Yes. The encoding object in OpenAPI and a Content-Type header per part allow a file plus JSON metadata in a single request.