REST works great while your API describes resources. But as soon as the domain becomes verb-shaped - recalculateInvoice, mergeAccounts, assignTask - 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 method + params, 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
composer create-project symfony/skeleton:"7.4.*" tasks-api
cd tasks-api
composer require otezvikentiy/json-rpc-api
If Flex has contrib recipes enabled, the bundle registers itself. If not, it is two lines by hand:
// config/bundles.php
return [
// ...
OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle::class => ['all' => true],
];
Wire up the route and a minimal config:
# config/routes/ov_json_rpc_api.yaml
ov_json_rpc_api:
resource: '@OVJsonRPCAPIBundle/config/routes/routes.yaml'
# config/packages/ov_json_rpc_api.yaml
ov_json_rpc_api:
access_control_allow_origin_list:
- 'http://localhost:8000'
The bundle registers a single route, /api/v{version} - every request goes through it. Note the CORS list format: these are full origins, scheme://host[:port], exactly as the browser sends them in the Origin header.
Check that it is alive:
php -S localhost:8000 -t public
curl -s -X POST http://localhost:8000/api/v1 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"test","params":{},"id":1}'
{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found."},"id":1}
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 #[JsonRPCAPI] 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:
// src/RPC/V1/CreateTask/CreateTaskRequest.php
declare(strict_types=1);
namespace App\RPC\V1\CreateTask;
final class CreateTaskRequest
{
private ?string $assigneeEmail = null;
public function __construct(private string $title)
{
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getAssigneeEmail(): ?string
{
return $this->assigneeEmail;
}
public function setAssigneeEmail(?string $assigneeEmail): void
{
$this->assigneeEmail = $assigneeEmail;
}
}
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:
// src/RPC/V1/CreateTask/CreateTaskResponse.php
declare(strict_types=1);
namespace App\RPC\V1\CreateTask;
final class CreateTaskResponse
{
public function __construct(
public readonly bool $success,
public readonly int $id,
public readonly string $title,
public readonly string $status,
public readonly ?string $assigneeEmail,
) {
}
}
And the method itself - an ordinary autowired service:
// src/RPC/V1/CreateTaskMethod.php
declare(strict_types=1);
namespace App\RPC\V1;
use App\RPC\V1\CreateTask\CreateTaskRequest;
use App\RPC\V1\CreateTask\CreateTaskResponse;
use App\Task\TaskStorage;
use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI;
use OV\JsonRPCAPIBundle\Core\ApiMethodInterface;
#[JsonRPCAPI(
methodName: 'createTask',
type: 'POST',
summary: 'Create a task',
tags: ['tasks'],
)]
final class CreateTaskMethod implements ApiMethodInterface
{
public function __construct(private readonly TaskStorage $storage)
{
}
public function call(CreateTaskRequest $request): CreateTaskResponse
{
$task = $this->storage->create($request->getTitle(), $request->getAssigneeEmail());
return new CreateTaskResponse(true, $task->id, $task->title, $task->status->value, $task->assigneeEmail);
}
}
TaskStorage 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: App\RPC\V1 -> /api/v1.
Call it:
curl -s -X POST http://localhost:8000/api/v1 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"createTask","params":{"title":"Try the demo","assigneeEmail":"alice@example.com"},"id":1}'
{"jsonrpc":"2.0","result":{"success":true,"id":1,"title":"Try the demo","status":"todo","assigneeEmail":"alice@example.com"},"id":1}
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. private string $title in the constructor means "required, string". ?string $assigneeEmail with a setter means "optional, string or null". Since version 5.0 the type comparison is strict - no silent coercion:
curl -s -X POST http://localhost:8000/api/v1 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"createTask","params":{"title":123},"id":4}'
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params. Additional info: [title] - This value should be of type string."},"id":4}
"123" will not quietly become a number, 1 will not become true - the client gets an immediate -32602 Invalid params 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 JRPCException - a thrown exception becomes a proper JSON-RPC error object:
// src/RPC/V1/GetTaskMethod.php (excerpt)
public function call(GetTaskRequest $request): GetTaskResponse
{
$task = $this->storage->find($request->getId());
if ($task === null) {
throw new JRPCException('Task not found.', JRPCException::SERVER_ERROR);
}
return new GetTaskResponse(true, $task->id, $task->title, $task->status->value, $task->assigneeEmail);
}
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Task not found."},"id":1}
The spec-defined codes (-32700...-32603) and the server range [-32099, -32000] are validated by the exception itself - you cannot accidentally invent an invalid code. Everything else - any unexpected Throwable - reaches the client as a generic -32603 Internal error, 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:
curl -s -X POST http://localhost:8000/api/v1 \
-H "Content-Type: application/json" \
-d '[{"jsonrpc":"2.0","method":"createTask","params":{"title":"One"},"id":1},
{"jsonrpc":"2.0","method":"createTask","params":{"title":"Two"},"id":2},
{"jsonrpc":"2.0","method":"listTasks","params":{},"id":3}]'
The response is an array of three results, matched by id. 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 (max_batch_size), 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:
# config/packages/ov_json_rpc_api.yaml (add this)
ov_json_rpc_api:
# ...
swagger:
api_v1:
api_version: '1'
base_path: 'http://localhost:8000'
info:
title: 'Tasks JSON-RPC API'
description: 'Task tracker built on JSON-RPC 2.0'
# .env
OV_JSON_RPC_API_SWAGGER_PATH=public/openapi/
php bin/console ov:swagger:generate
The output is public/openapi/api_v1.yaml 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 -
getTask,listTaskswith a status filter,updateTask,deleteTask; -
API versioning: a paginated
listTasksv2 lives at/api/v2without touching v1 - it is just a second namespace,App\RPC\V2; - 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
WebTestCase- 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.
Top comments (0)