srio/rest-upload-bundle
Symfony bundle for handling file uploads over a REST API, offering upload endpoints, configurable storage and validation, and support for multiple files—ideal for adding file-upload capabilities to Symfony applications.
Install and register (Symfony 2.8/3.x era):
composer require srio/rest-upload-bundle
// app/AppKernel.php
$bundles = [
// ...
new SRIO\RestUploadBundle\SRIORestUploadBundle(),
];
Look at these four files first, in this order — they are the whole package:
Upload/UploadHandler.php — the front door; a chain that asks each registered handler "can you handle this request?" and dispatches to the first one that says yes.Upload/Handler/* — one class per accepted wire format (multipart form data, base64-in-JSON, chunked/partial uploads).DependencyInjection/Configuration.php + Resources/config/services.xml — the handlers list and the real service ids (confirm these before copy-pasting config; ids and class names shifted between tags).Tests/ — the most reliable documentation of the exact request shapes each handler expects.The first use case: one endpoint that accepts a file however the client feels like sending it — multipart/form-data from a classic form today, base64 JSON from a mobile/canvas client tomorrow, chunked uploads later — without touching the controller.
// src/AppBundle/Controller/UploadController.php
public function uploadAction(Request $request)
{
$result = $this->get('srio_rest_upload.handler')->handle($request, function ($file) {
$name = bin2hex(random_bytes(8)).'.'.($file->guessExtension() ?: 'bin');
$file->move($this->getParameter('uploads_dir'), $name);
return ['id' => $name]; // return scalars/arrays, not entities
});
return new JsonResponse($result);
}
That callback is where 90% of your real work lives: validation, naming, storage, and persistence. The bundle only normalises the transport.
Keep the controller a pass-through, put logic in a service. The handler is a normal service; injecting it beats $this->get() (and survives the Symfony 4 migration if you fork):
class AvatarUploader
{
public function __construct(private UploadHandler $handler, private string $uploadsDir) {}
public function store(Request $request): array
{
return $this->handler->handle($request, function ($file) {
$this->assertValid($file); // mime, size, dimensions
$name = $this->generateName($file);
$file->move($this->uploadsDir, $name);
return ['id' => $name, 'url' => '/uploads/'.$name];
});
}
}
This makes the upload path unit-testable with mocked handlers, and callable from a console command or message consumer when files arrive out-of-band.
Exploit format polymorphism instead of branching. The point of the package is that the controller is identical for:
// multipart — plain HTML form or FormData
$client->request('POST', '/api/uploads', [], ['file' => $uploadedFile]);
// base64 — SPA / mobile camera payload
$client->request('POST', '/api/uploads', [], [], ['CONTENT_TYPE' => 'application/json'],
json_encode(['name' => 'avatar.png', 'content' => base64_encode($bytes)]));
// chunked — resumable, PUT-style with Content-Range
$client->request('PUT', '/api/uploads', [], [], [
'CONTENT_TYPE' => 'application/octet-stream',
'CONTENT_RANGE' => 'bytes 0-1048575/5242880',
'X-File-Name' => 'video.mp4',
], $chunkBytes);
Write one functional test per format against the same route; that suite is your real safety net if you later swap the package out.
Two-phase / resumable flow. For large files, use the chunked handler as a state machine: each request carries a range and a stable upload identifier; respond with the next expected offset so the client can resume after a dropped connection. The server-side offset is the source of truth — never trust the client's idea of "where it got to".
Persist after assembly, not per chunk. For chunked uploads, write the Upload/Media entity only in the final-chunk branch (when end + 1 === total). Intermediate chunks should touch temp storage only. Encode this as an explicit $context/return value from the callback rather than sniffing headers in your entity listener.
Custom handler for anything the bundle doesn't speak (S3 presigned POST, tus, application/vnd.api+json): implement the handler interface (isSupported(Request) + handle(...)), register it as a service, and prepend it to the handlers list. That is the sanctioned extension point and it's a small interface.
$this->get(), Sensio @Route annotation namespace, UploadedFile::move(). Budget for a fork (namespace + constructor injection + Symfony 4/5 signatures) or plan a replacement — vich/uploader-bundle for storage, oneup/uploader-bundle or tus-php if you need resumability. Do not adopt it greenfield without that exit plan written down.multipart/form-data too, so a generic multipart handler placed first will swallow them. Put the partial/chunked handler before multipart in the handlers list and verify with a request that only the chunked handler could satisfy.Content-Type. multipart/form-data; boundary=... vs application/x-www-form-urlencoded vs application/json; also check the client isn't sending Content-Type: text/plain for a raw PUT. Log the resolved handler name at debug level — it saves an hour every time.post_max_size, upload_max_filesize, memory_limit, and client_max_body_size (nginx) all matter. Exceeding post_max_size yields an empty $_POST/$_FILES with no error — detect it by comparing $_SERVER['CONTENT_LENGTH'] against the limit in a request listener.../../), NUL bytes, and Unicode tricks; guessExtension() and getClientMimeType() are both client-adjacent. Generate your own name, store outside the webroot, and re-validate mime/size after assembly, not per chunk.SELECT ... FOR UPDATE) around the append.PUT/PATCH and the custom headers (Content-Range, X-File-Name, whatever identifier header you use). NelmioCorsBundle config is the usual fix; test with a real browser, not just curl.JsonResponse.$request->getContentType(), $request->headers->all(), and strlen($request->getContent()); reproduce the failing client request once there, then diff against the shapes in Tests/. It's faster than reading the handler chain.dev- commit hash), vendor the code if you can, and keep a HandlerInterface shim in your own codebase so a swap is a service definition change rather than a rewrite.How can I help you explore Laravel packages today?