REST works great while your API describes resources.
But as soon as the domain becomes verb-shaped - , , - you end up bending verbs into nouns and arguing about which HTTP method cancels an order.
JSON-RPC 2.0 cuts through all of that: every call is just + , one endpoint, a spec that fits on two pages, and batching out of the box.
In this article we will build a working JSON-RPC 2.0 API on Symfony: a task tracker with DTO validation, batch requests and generated OpenAPI documentation.
There is surprisingly little code to write: methods are declared with attributes, validation is derived from property types, and Swagger is generated by a console command.
Everything below lives as a ready-to-run project on GitHub: symfony-jsonrpc-api-demo - clone it and poke it with curl while you read.
We will use the otezvikentiy/json-rpc-api bundle (PHP 8.2-8.5, Symfony 6.4/7/8; this article uses PHP 8.4 and Symfony 7.4).
Full disclosure: I am the author of the bundle.
It has been running in production for three years - internal fintech tooling, an HRM system - nothing glamorous load-wise, but the correctness, logging and audit requirements were real, and they shaped most of what you will see below.
Installation If Flex has contrib recipes enabled, the bundle registers itself.
If not, it is two lines by hand: Wire up the route and a minimal config: The bundle registers a single route, - every request goes through it.
Note the CORS list format: these are full origins, , exactly as the browser sends them in the header.
Check that it is alive: A well-formed JSON-RPC "method not found" error means the transport works.
Now let's add methods.
The first method: createTask A method in this bundle is three classes: a Request (what comes in), a Response (what goes out) and the method itself carrying the attribute.
No YAML manifests, no base controllers to extend.
The Request describes the parameters.
The rule is simple: constructor parameters are required, properties with setters are optional: The Response is a plain class; whatever the class makes public - a public getter or a public property - ends up in the JSON.
Promoted constructor properties keep it short: And the method itself - an ordinary autowired service: here is a trivial JSON-file store (about 80 lines in the demo repo; in a real project a Doctrine repository takes its place - the method contract does not change).
The API version is derived from the namespace: -> .
Call it: Validation you don't have to write The best part: the bundle builds the validator set itself, from the PHP types of the Request class. in the constructor means "required, string". with a setter means "optional, string or null".
Since version 5.0 the type comparison is strict - no silent coercion: will not quietly become a number, will not become - the client gets an immediate with a readable explanation.
Your request contract is literally the PHP types of your DTO.
Domain errors: JRPCException For errors like "task not found" the bundle ships - a thrown exception becomes a proper JSON-RPC error object: The spec-defined codes (...) and the server range are validated by the exception itself - you cannot accidentally invent an invalid code.
Everything else - any unexpected - reaches the client as a generic , while the full stack trace goes to the log only.
No leaking file paths or class names to the outside world.
Batches: N calls, one HTTP request This is a protocol feature rather than a bundle feature, but here it works out of the box - send an array of requests instead of a single object: The response is an array of three results, matched by .
Where a REST client makes N round-trips (and the frontend shows N spinners), this is one request.
The maximum batch size is capped by config (), so a million-call batch DoS does not get through.
OpenAPI from the same source of truth The method attributes and DTO types are the single source of truth - and the OpenAPI 3.1 document is generated from them: The output is with schemas for every Request/Response - ready for Swagger UI, Postman or client SDK generation.
The docs cannot drift away from the code, because they are made from it.
What else is in the demo The demo repo takes the same application further, and you can see the rest working there: the full CRUD - , with a status filter, , ; API versioning: a paginated v2 lives at without touching v1 - it is just a second namespace, ; sane default limits: body size, JSON depth, batch size - all configurable, all covered by tests; request logging with masking of sensitive fields by regex patterns (29 built-in patterns: password, token, card_number, ...); functional tests through a regular - 26 tests over the real endpoint.
Each of those deserves its own write-up - tell me in the comments which one to start with.
Links The bundle: github.com/OtezVikentiy/symfony-jsonrpc-api-bundle - 768 tests, 99% coverage, a mutation-testing gate in CI, semver with an explicit BC policy.
The demo: github.com/OtezVikentiy/symfony-jsonrpc-api-demo Questions and ideas: Discussions; confirmed bugs: Issues.
Feedback is welcome - including the harsh kind.
This project has learned a lot from it.