JSON has no representation for a file. Strings, numbers, arrays, objects - that is the whole list. So every JSON-RPC API eventually runs into the same question: how do you accept a file upload - a photo, a scan, a PDF - when the protocol itself cannot carry binary data?
The usual answer is: you don't. The file goes to a separate, ordinary controller that reads $request->files, and the JSON-RPC layer handles everything else next to it. And now you have exactly the ad hoc endpoint sprawl that JSON-RPC was supposed to remove.
In otezvikentiy/json-rpc-api 5.2 there is a different answer. And more interesting than the feature is how it came about: I did not write it - an external contributor did. But first things first.
Full disclosure: I am the author of the bundle, and I have maintained it alone for almost three years. That is exactly why a release whose headline feature was written by someone else feels like a different kind of event to me.
The problem
Straight from issue #8: two services exchange scanned images plus structured metadata (tenant, station, session) on the same call - something like captures.create(tenantId, stationId, image). Today image cannot be expressed as a parameter of a JSON-RPC method, so that call has to live outside the bundle as a separate multipart controller.
What you want is for the method to simply declare a parameter of type UploadedFile and get the file, like any other parameter.
The solution: multipart as a transport adapter
The key idea is to leave the core untouched. A multipart/form-data request is normalized into the very same JSON-RPC envelope an ordinary request produces, only with UploadedFile objects already sitting inside params. Everything below the transport - hydration, batching, validation - stays completely unaware of multipart, exactly the way it is unaware that a GET request's payload came from a query string.
The wire format: one text part named jsonrpc carries the full JSON-RPC envelope as a string (all scalar parameters live inside it), and every other part is a file, its part name being the parameter name.
curl -X POST http://localhost/api/v1 \
-F 'jsonrpc={"jsonrpc":"2.0","method":"captures.create","params":{"tenantId":"t-1"},"id":1}' \
-F 'image=@scan.png'
The method declares the parameter as an ordinary DTO property:
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class Request
{
private string $tenantId = '';
private ?UploadedFile $image = null;
public function getTenantId(): string { return $this->tenantId; }
public function setTenantId(string $tenantId): void { $this->tenantId = $tenantId; }
public function getImage(): ?UploadedFile { return $this->image; }
public function setImage(?UploadedFile $image): void { $this->image = $image; }
}
And reads it in the handler as a real UploadedFile - with move(), getClientOriginalName(), the whole Symfony surface.
Three decisions worth explaining
Scalars do not become form fields. The temptation was to spread every parameter across separate form fields. But a form field is a string, and then "42" and 42 become indistinguishable again - the untyped-transport ambiguity the bundle deliberately tolerates only for GET, where a query string leaves no choice. POST has types, and losing them is not worth it. So scalars stay in the JSON envelope, and only files travel as form parts.
It is switched on twice. One switch is multipart.enabled for the application, the other is acceptsMultipart: true on the method's attribute. This is not belt-and-suspenders: the Content-Type is checked before any method is known, so the global switch alone cannot say anything about a particular method. And turning the transport on for the application while silently opening it to every method already written - none of which expected it - is the wrong default.
Validation is Symfony's own, not hand-rolled. A declared UploadedFile compiles to Assert\Type followed by Assert\File, through the same machinery that produces Assert\Type('int') for an int field. The size limit (multipart.max_file_bytes, in Symfony's own notation such as '10Mi') is enforced by Assert\File, and it brings the handling of all eight PHP upload error codes with it. A failed upload (upload_max_filesize exceeded, a partial transfer, no temp directory) comes back as -32602 naming the field, rather than as an unusable UploadedFile reaching the method.
Honest about security
multipart/form-data is a CORS "simple request", exactly like form-encoded. And the mandatory Content-Type: application/json introduced in 5.0 was precisely what closed that CSRF vector. So enabling multipart reopens it - but for the methods that declare acceptsMultipart: true, and only those.
The bundle does not pretend it solved this for you. The docs warn loudly: before switching it on, make sure at least one of these holds for the affected methods - authentication does not travel in cookies (a header token is not CORS-safelisted), or the session cookie is SameSite=Lax/Strict, or the method checks a CSRF token. The two-level opt-in limits the blast radius; the rest is the application's decision, not the bundle's.
The first-version limits are stated plainly too: batch stays JSON-only, files at the top level of params only, POST only.
How it came about - and why that matters more than the feature
I did not write this code. It started with issue #8: tacman described the problem, proposed two shapes (a minimal one and one modeled on the GraphQL multipart spec), mapped out honestly where it would touch hydration, and asked for direction before writing anything. We settled the shape in the comments. A few days later came PR #9: seven commits, a green CI across the whole matrix including the coverage and mutation-testing gates, plus a branch on the demo application so the feature could be run rather than only read.
In a couple of places his solution was better than the sketch in the issue - in particular compiling Assert\File from the config, which I had not planned. The review took one pass: run his branch locally (822 tests, Infection MSI above the gate, PHPStan and cs-fixer clean), read the whole diff, and leave a few non-blocking notes. Merging was not scary.
For a maintainer who carried the project alone for three years, a first serious external PR - a careful one, with tests and a demo - is worth more than any number of stars. It is the first sign that something living is forming around the project, and probably the best outcome one could hope for when opening the source. Thank you, tacman.
Install and try it
composer require otezvikentiy/json-rpc-api:^5.2
- The full release: 5.2 on GitHub.
- Feature docs (wire format, config, the error catalogue, and the patterns for cases outside its scope - base64 for small payloads, a two-step upload for large ones): docs/multipart.md.
- The demo project where it all works together: symfony-jsonrpc-api-demo.
The bundle: github.com/OtezVikentiy/symfony-jsonrpc-api-bundle. Questions and ideas in Discussions, bugs in Issues. As this story shows, a good issue sometimes turns into a feature - feedback of any kind is welcome.
Top comments (0)