otezvikentiy / json-rpc-api
Symfony bundle for building JSON-RPC 2.0 APIs: attribute-driven methods, DTO validation, batch requests, API versioning, OpenAPI/Swagger generation and hardened security defaults
Package info
github.com/OtezVikentiy/symfony-jsonrpc-api-bundle
Type:symfony-bundle
pkg:composer/otezvikentiy/json-rpc-api
Requires
- php: 8.2.* || 8.3.* || 8.4.* || 8.5.*
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/routing: ^6.4 || ^7.0 || ^8.0
- symfony/security-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/serializer: ^6.4 || ^7.0 || ^8.0
- symfony/string: ^6.4 || ^7.0 || ^8.0
- symfony/validator: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.32 || ^0.34
- phpstan/phpstan: ^2.0
- phpunit/phpunit: 11.*
- symfony/property-access: ^6.4 || ^7.0 || ^8.0
This package is auto-updated.
Last update: 2026-08-17 20:32:12 UTC
README
A Symfony bundle for fast and convenient creation of JSON-RPC 2.0 API applications.
GitHub: https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle
Design Goals
- Strict JSON-RPC 2.0 compliance. Requests, batches, notifications, error objects and
idsemantics follow the specification precisely, and the test suite pins that behaviour down. - Attribute-driven, DTO-first. An API method is a single class with a
#[JsonRPCAPI]attribute; its request and response are plain PHP objects with typed properties. No per-method routing configuration, no schema files to keep in sync with the code. - Safe defaults. Payload, nesting and batch limits, error sanitization, CORS whitelisting and log masking work out of the box; every deliberate trade-off is written down in SECURITY.md.
- Documentation from the same source of truth. OpenAPI/Swagger documentation is generated from the same attributes and DTOs the runtime uses, so it cannot drift away from the actual behaviour.
- Small dependency footprint. Only
symfony/*components andpsr/log— nothing else is pulled into your project. - Every supported combination is tested. CI runs the full matrix of PHP 8.2–8.5 against Symfony 6.4, 7 and 8, plus a lowest-dependencies build, a coverage gate and a mutation-testing gate.
Want to see all of this working together? Check the demo application: symfony-jsonrpc-api-demo — a small task-tracker API showcasing attributes, DTO validation, batches, API versioning, OpenAPI generation and log masking.
Features
- Full JSON-RPC 2.0 specification compliance
- Method configuration via PHP 8 attributes (
#[JsonRPCAPI(...)]) - HTTP methods support: POST, GET, PUT, PATCH, DELETE
- API versioning (
/api/v1,/api/v2, ...) - Automatic OpenAPI/Swagger documentation generation
- Pre- and Post-processors (middleware)
- Batch requests
- Built-in request validation
- Role-based access control via Symfony Security
- Binary response support (images, documents)
- File uploads over
multipart/form-data(opt-in, off by default)
Requirements
- PHP 8.2 – 8.5
- Symfony ^6.4 || ^7.0 || ^8.0
Installation
composer require otezvikentiy/json-rpc-api
With Symfony Flex that is the whole installation: the official contrib recipe registers the bundle, creates config/packages/ov_json_rpc_api.yaml and config/routes/ov_json_rpc_api.yaml, and adds the environment variables to .env. Flex asks for confirmation before executing a contrib recipe; to pre-approve them, run composer config extra.symfony.allow-contrib true once. Review the generated config and adjust it to your project.
Without Flex, configure everything manually. Enable the bundle:
// config/bundles.php return [ // ... OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle::class => ['all' => true], ];
Create configuration files:
# 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: - '*' swagger: api_v1: api_version: '1' base_path: '%env(string:OV_JSON_RPC_API_BASE_URL)%' base_path_description: 'Production server' test_path: '%env(string:OV_JSON_RPC_API_TEST_URL)%' test_path_description: 'Sandbox server' auth_token_name: 'X-AUTH-TOKEN' auth_token_test_value: '%env(string:OV_JSON_RPC_API_AUTH_TOKEN)%' info: title: 'My API' description: 'JSON-RPC 2.0 API' terms_of_service_url: 'https://example.com/tos' contact: name: 'Support' url: 'https://example.com' email: 'support@example.com' license: 'MIT' licenseUrl: 'https://opensource.org/licenses/MIT'
# .env OV_JSON_RPC_API_SWAGGER_PATH=public/openapi/ OV_JSON_RPC_API_BASE_URL=http://localhost OV_JSON_RPC_API_TEST_URL=http://localhost OV_JSON_RPC_API_AUTH_TOKEN=your_test_token_here
Detailed instructions: docs/installation.md
Quick Start
1. Create a Request
// src/RPC/V1/GetProduct/Request.php namespace App\RPC\V1\GetProduct; class Request { private int $id; private string $title; public function __construct(int $id) { $this->id = $id; } public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getTitle(): string { return $this->title; } public function setTitle(string $title): void { $this->title = $title; } }
2. Create a Response
// src/RPC/V1/GetProduct/Response.php namespace App\RPC\V1\GetProduct; class Response { private bool $success; private string $title; private int $price; public function __construct(bool $success = true) { $this->success = $success; } public function isSuccess(): bool { return $this->success; } public function setSuccess(bool $success): void { $this->success = $success; } public function getTitle(): string { return $this->title; } public function setTitle(string $title): void { $this->title = $title; } public function getPrice(): int { return $this->price; } public function setPrice(int $price): void { $this->price = $price; } }
3. Create an API Method
// src/RPC/V1/GetProductMethod.php namespace App\RPC\V1; use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI; use OV\JsonRPCAPIBundle\Core\ApiMethodInterface; use App\RPC\V1\GetProduct\Request; use App\RPC\V1\GetProduct\Response; #[JsonRPCAPI(methodName: 'getProduct', type: 'POST')] class GetProductMethod implements ApiMethodInterface { public function call(Request $request): Response { $response = new Response(); $response->setTitle('Iphone 15'); $response->setPrice(2000); return $response; } }
4. Call the API
curl -X POST http://localhost/api/v1 \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "getProduct", "params": {"id": 1, "title": "test"}, "id": "1"}'
Response:
{
"jsonrpc": "2.0",
"result": {
"success": true,
"title": "Iphone 15",
"price": 2000
},
"id": "1"
}
Architecture
Request Processing Pipeline
HTTP POST /api/v{version}
|
v
ApiController
|
v
RequestRawDataHandler --- parses HTTP request (JSON body / query params)
|
v
BatchStrategyFactory --- determines: single or batch request
|
v
RequestHandler
|--- Lookup MethodSpec by method name
|--- Create Request object from parameters
|--- Validate typed properties
|--- PreProcessors (if any)
|--- Method::call(Request) -> Response
|--- PostProcessors (if any)
|
v
ResponseService --- serializes response into JSON-RPC 2.0 format
API Method Project Structure
src/RPC/V1/
GetProductMethod.php # Method class with #[JsonRPCAPI] attribute
GetProduct/
Request.php # Incoming request DTO
Response.php # Response DTO
Classes marked with the #[JsonRPCAPI] attribute are automatically discovered and registered by the bundle.
Examples
| Example | Description | Files |
|---|---|---|
| Basic | Simplest example of creating an API method | Request, Response, Method |
| Pre/Post-processors | Executing logic before and after method call | Request, Response, Method, AbstractMethod |
| Array of objects | Returning a collection of objects in the response | Request, Response, Method, Product |
| Binary response | Returning images, documents and other binary data | Request, PlainResponse, Method |
Additional Documentation
| Section | Description |
|---|---|
| Error Handling | Error codes, JRPCException, custom errors, additionalInfo |
| Notification Requests | Requests without id, strict_notifications parameter |
| Parameter Validation | Automatic type validation, nullable, error format |
| JsonRpcRequest Base Class | toArray() method, recursive serialization |
| Partial updates (JSON Merge Patch) | PartialRequestInterface, wasProvided(), RFC 7396 semantics |
| File uploads | multipart/form-data, acceptsMultipart, limits, and the CSRF trade-off |
| Troubleshooting / FAQ | Common problems and their solutions |
| Upgrade Guide 4.x → 5.0 | Every BC-breaking change in 5.0, what breaks and what to do about it |
| CHANGELOG | Version history |
Logging
Optional Request/Response logging subsystem with sensitive-data masking through a PSR-3 logger. Disabled by default. Details — docs/logging.md.
Partial updates (JSON Merge Patch)
The bundle supports PATCH semantics per RFC 7396 for Update methods where the client sends only changed fields.
Problem: the standard if ($request->getX() !== null) { $entity->setX($request->getX()); } pattern cannot distinguish "field not in payload" from "field sent as null" — both give null on the DTO. This means a field cannot be cleared via PATCH.
Solution: the Request DTO implements PartialRequestInterface, and the framework tracks which fields actually arrived in the payload. The service layer uses wasProvided('x') instead of !== null:
use OV\JsonRPCAPIBundle\Core\Request\PartialUpdateRequest; class UpdateUserRequest extends PartialUpdateRequest { private ?int $id = null; private ?string $email = null; private ?string $bio = null; // getters/setters... }
public function call(UpdateUserRequest $request): Response { $user = $this->userRepository->find($request->getId()); if ($request->wasProvided('email')) { $user->setEmail($request->getEmail()); // null = clear } if ($request->wasProvided('bio')) { $user->setBio($request->getBio()); } // ... }
Payload semantics:
| Payload | wasProvided |
Service behavior |
|---|---|---|
{"email": "new@x.com"} |
true |
set the new value |
{"email": null} |
true |
clear the field (null) |
{} (key absent) |
false |
leave the field untouched |
Opt-in: only DTOs implementing PartialRequestInterface get tracking. Existing DTOs work without changes (full backward compatibility).
Details and edge cases — in docs/partial_updates.md.
API Versioning
API version is determined from the URL (/api/v1, /api/v2) or explicitly via the version parameter in the attribute:
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', version: 2)]
If version is not specified, it's extracted from the class namespace (e.g., App\RPC\V1 -> version 1).
Batch Requests
The bundle supports batch JSON-RPC requests per specification:
curl -X POST http://localhost/api/v1 \ -H "Content-Type: application/json" \ -d '[ {"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 4], "id": "1"}, {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"} ]'
Pre- and Post-processors
Processors allow executing logic before and after API method calls (logging, audit, notifications, etc.):
use OV\JsonRPCAPIBundle\Core\PreProcessorInterface; use OV\JsonRPCAPIBundle\Core\PostProcessorInterface; #[JsonRPCAPI(methodName: 'getProduct', type: 'POST')] class GetProductMethod implements PreProcessorInterface, PostProcessorInterface { public function getPreProcessors(): array { return [ static::class => ['logRequest'], ]; } public function getPostProcessors(): array { return [ static::class => ['logResponse'], ]; } public function logRequest(string $processorClass, ?object $request = null): void { // Called BEFORE call() } public function logResponse(string $processorClass, ?object $request = null, ?OvResponseInterface $response = null): void { // Called AFTER call() } public function call(Request $request): Response { // Main logic } }
Details: docs/examples/pre-and-post-processors.md
Swagger / OpenAPI
Generating Documentation
bin/console ov:swagger:generate
Generates public/openapi/api_v1.yaml file for use with Swagger UI.
Documentation Annotations
Scalar properties:
use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerProperty; class Response { #[SwaggerProperty(default: 'true', example: 'true')] private bool $success; #[SwaggerProperty(format: 'email', example: 'user@example.com')] private string $email; }
Arrays:
use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerArrayProperty; class Response { #[SwaggerArrayProperty(type: 'string')] private array $errors = []; #[SwaggerArrayProperty(type: Product::class, ofClass: true)] private array $products = []; }
Tags for grouping:
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', tags: ['products'])]
Details:
Security
Role-Based Access
Restrict method access by roles via the roles attribute:
#[JsonRPCAPI(
methodName: 'deleteUser',
type: 'POST',
roles: ['ROLE_ADMIN', 'ROLE_SUPER_ADMIN']
)]
class DeleteUserMethod implements ApiMethodInterface
{
public function call(Request $request): Response { /* ... */ }
}
If the user lacks the required role, the bundle returns a normal JSON-RPC error object with code -32000 and HTTP status 200 — not HTTP 403: {"jsonrpc": "2.0", "error": {"code": -32000, "message": "Access denied."}, "id": ...}.
Authentication
The bundle is compatible with any Symfony authentication method:
Testing
Run the test suite:
./vendor/bin/phpunit tests/
Coverage reports require a coverage driver (xdebug or pcov). The phpunit.xml.dist config already declares the <source> block for PHPUnit 10+ coverage output.
Mutation testing (also requires a coverage driver):
composer infection
The bundled test suite covers:
- Unit tests — every Core component, services, request/response models, DI, Swagger models.
- Integration tests — full request lifecycle through the controller.
- Command tests — Swagger YAML generation.
- Security regression tests (
tests/Security/) — DoS limits (payload, batch, DTO depth, array size), error sanitization, CORS origin matching, setter visibility, command path containment.
See docs/testing.md for guidance on writing tests for your own RPC methods.
Configuration
ov_json_rpc_api Parameters
| Parameter | Default | Description |
|---|---|---|
access_control_allow_origin_list |
[] |
Allowed CORS origins. Use ['*'] for wildcard, or list exact origins for matching against the request Origin header. Origins outside the list receive no CORS header at all — the legacy comma-joined fallback no longer exists. |
cors_allowed_headers |
['Content-Type'] |
Headers allowed in the CORS preflight response (Access-Control-Allow-Headers). The bundle handles OPTIONS preflight requests itself. |
strict_notifications |
true |
Strict JSON-RPC 2.0 Notification compliance. When true — server does not respond to notifications (per spec). When false — server returns a response even for notifications if the result is non-empty (legacy 3.x behaviour). |
allow_extra_fields |
false |
When false, request params containing fields not declared on the Request DTO are rejected with INVALID_PARAMS. Can be overridden per-method via the #[JsonRPCAPI(allowExtraFields: true)] attribute. |
expose_internal_errors |
false |
When false (production-safe), uncaught non-JRPCException throwables are replaced with a generic Internal error. payload and the original is sent to the logger. Set to true only in dev to expose raw exception messages. |
max_payload_bytes |
1048576 |
Maximum bytes accepted for the raw request body. Larger requests are rejected with INVALID_REQUEST. |
max_json_depth |
64 |
Maximum allowed JSON nesting depth when decoding the payload. Deeper inputs are rejected with PARSE_ERROR. |
max_batch_size |
50 |
Maximum number of requests allowed in a single JSON-RPC batch. Larger batches return a single INVALID_REQUEST error. |
max_dto_depth |
10 |
Maximum recursion depth when hydrating nested Request DTO objects. Prevents stack/memory exhaustion via deeply nested payloads. |
max_array_param_size |
1000 |
Maximum element count for array parameters bound through addX() adders. |
logging.enabled |
false |
Request and response logging. Everything else under logging.* applies only when this is true. |
logging.request_level |
'info' |
PSR-3 level for the incoming request entry. |
logging.response_level |
'info' |
PSR-3 level for a successful response entry. |
logging.error_response_level |
'warning' |
PSR-3 level for an error response entry. |
logging.max_body_length |
8192 |
Truncation of request and response bodies in the log, in characters. 0 disables truncation. The 4.x default was 0. |
logging.skip_plain_responses |
true |
Do not log the body of PlainResponseInterface responses (files, streams). |
logging.logger_service |
null |
Service id of the PSR-3 logger JsonRpcCallLogger writes to. |
logging.call_logger_service |
null |
Service id replacing the JsonRpcCallLoggerInterface implementation outright. |
logging.masking.placeholder |
'***' |
What replaces the value of a masked field. |
logging.masking.key_patterns |
29 patterns | Regular expressions for field and header names whose values are masked (password, token, secret, authorization, jwt and others). The 4.x default was [], meaning no masking at all. Supplying your own list replaces the defaults rather than adding to them. An invalid expression fails container compilation. |
swagger |
— | Swagger configuration per API version. |
swagger.*.api_version |
'1' |
API version number. |
swagger.*.base_path |
— | Production server URL. |
swagger.*.test_path |
null |
Test server URL. |
swagger.*.base_path_variables |
[] |
Variables for base_path substitution. |
swagger.*.test_path_variables |
[] |
Variables for test_path substitution. |
swagger.*.auth_token_name |
— | Authorization token header name. |
swagger.*.auth_token_test_value |
— | Test token value. Currently unused: only auth_token_name reaches the OpenAPI security scheme; the value is substituted nowhere. Kept so existing configs keep compiling. |
swagger.*.info |
— | API information (title, description, contact, license). |
Security hardening: see docs/security_hardening.md for recommended values, rationale, and tuning tips for high-volume APIs.
#[JsonRPCAPI] Attribute Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
methodName |
string | yes | — | JSON-RPC method name |
type |
string | yes | — | HTTP method (POST, GET, PUT, PATCH, DELETE) |
version |
?int | no | null |
API version (if null — determined from namespace) |
summary |
string | no | '' |
Short description for Swagger |
description |
string | no | '' |
Detailed description for Swagger |
tags |
?array | no | null |
Tags for Swagger grouping |
roles |
array | no | [] |
Required roles for access |
ignoreInSwagger |
bool | no | false |
Exclude method from Swagger documentation |
group |
?string | no | null |
Swagger path group (e.g., 'products' -> /products/get_product) |
allowExtraFields |
bool | no | false |
Accept fields in params that the Request DTO does not declare. Overrides the global allow_extra_fields for this method and applies at every nesting level. |
JSON-RPC Error Codes
| Code | Constant | Description |
|---|---|---|
-32700 |
PARSE_ERROR |
JSON parsing error |
-32600 |
INVALID_REQUEST |
Invalid JSON-RPC request |
-32601 |
METHOD_NOT_FOUND |
Method not found |
-32602 |
INVALID_PARAMS |
Invalid parameters |
-32603 |
INTERNAL_ERROR |
Internal error |
-32000 |
SERVER_ERROR |
Server error |
Versioning and Backward Compatibility
The bundle follows Semantic Versioning:
- Major releases (
4.0,5.0, ...) may change behaviour and the public API. Every breaking change is listed in CHANGELOG.md and comes with an upgrade guide (docs/upgrade-5.0.md, docs/upgrade-4.0.md). - Minor releases add functionality without breaking existing integrations; behaviour under existing configuration defaults does not change.
- Patch releases contain bug and security fixes only.
The backward-compatibility promise covers everything an integrator touches:
- the configuration schema under
ov_json_rpc_api.*and its defaults; - the
#[JsonRPCAPI]attribute and its parameters; - the wire format — JSON-RPC 2.0 request, response, error and batch shapes;
- the interfaces the integrator implements or consumes:
ApiMethodInterface,PreProcessorInterface,PostProcessorInterface,PlainResponseInterface,JsonRpcCallLoggerInterface,JsonRpcLogFormatterInterface,SensitiveDataMaskerInterface,ContextIdGeneratorInterface; - the
ov:swagger:generatecommand and the structure of the OpenAPI output.
Internal service classes not listed above are implementation detail and may change in a minor release. Functionality scheduled for removal in the next major is, when feasible, deprecated in a preceding minor release and noted in the CHANGELOG.
The list of versions currently receiving fixes is in SECURITY.md.
Contributing
See CONTRIBUTING.md for the development setup, test requirements, and PR expectations. To report a vulnerability, see SECURITY.md — please do not open a public issue.
License
Author
Leonid Groshev — OtezVikentiy@gmail.com — otezvikentiy.tech