knetesin / json-rpc-server
Modern JSON-RPC 2.0 server bundle for Symfony with DTOs, validation, streaming, MCP, caching, rate limiting, and built-in observability.
Package info
github.com/knetesin/json-rpc-server
Type:symfony-bundle
pkg:composer/knetesin/json-rpc-server
Requires
- php: >=8.3
- ext-json: *
- phpdocumentor/type-resolver: ^2.0
- phpstan/phpdoc-parser: ^2.3
- psr/cache: ^2.0 || ^3.0
- psr/container: ^1.1 || ^2.0
- psr/event-dispatcher: ^1.0
- psr/log: ^1 || ^2 || ^3
- symfony/config: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/expression-language: ^7.0 || ^8.0
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/property-info: ^7.4 || ^8.0
- symfony/routing: ^7.0 || ^8.0
- symfony/security-core: ^7.0 || ^8.0
- symfony/serializer: ^7.0 || ^8.0
- symfony/validator: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.13
- open-telemetry/sdk: ^1.0 || ^2.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5.23 || ^12.0
- sentry/sentry-symfony: ^5.0 || ^6.0
- symfony/cache: ^7.0 || ^8.0
- symfony/expression-language: ^7.0 || ^8.0
- symfony/framework-bundle: ^7.0 || ^8.0
- symfony/http-client: ^7.0 || ^8.0
- symfony/maker-bundle: ^1.50
- symfony/rate-limiter: ^7.0 || ^8.0
- symfony/security-bundle: ^7.0 || ^8.0
Suggests
- ext-apcu: Recommended when `parallel_batch.budget_store: apcu` (the default) — gives the executor a host-wide concurrency budget shared across PHP-FPM workers, preventing pool exhaustion. Without APCu the budget degrades to no-op (per-batch cap still applies).
- open-telemetry/sdk: Enables `json_rpc_server.opentelemetry.enabled` — vendor-neutral traces / metrics / W3C trace-context propagation for every RPC call (works with Jaeger, Datadog, Grafana Tempo, Honeycomb, AWS X-Ray, etc). The bridge subscriber is registered only when this package is installed.
- sentry/sentry-symfony: Enables `json_rpc_server.sentry.enabled` — breadcrumbs, `rpc.method` tag, and optional child spans for every RPC call. The bridge subscriber is registered only when this package is installed.
- symfony/cache: Required only for tag-aware cache invalidation (RpcCacheInvalidator::purgeMethod / purgeTags). Plain PSR-6 caching works without it; tag operations silently no-op.
- symfony/http-client: Required only if `json_rpc_server.parallel_batch.enabled` is true — the executor uses it for loopback fan-out of batch items to the worker pool. Without it, parallel batch is unavailable; sequential batch processing always works.
- symfony/maker-bundle: Enables `bin/console make:rpc-method` to scaffold handler + optional DTO + optional test.
- symfony/rate-limiter: Required only if any RPC method carries #[Rpc\RateLimit]. The bundle fails the container build with a clear message if it's missing but referenced.
- symfony/security-bundle: Role checks on RPC methods, authenticated Context user, and per-user rate limit / cache scopes
- symfony/web-profiler-bundle: Display RPC method calls in the Symfony Web Profiler toolbar and panel (requires kernel.debug)
This package is auto-updated.
Last update: 2026-05-23 20:25:09 UTC
README
A modern JSON-RPC 2.0 server for Symfony — built around attributes, DTOs, and the rest of the framework you already use. Speaks JSON-RPC for your own clients, MCP for AI agents, and NDJSON / SSE when you need to stream.
#[Rpc\Method('user.update', roles: ['ROLE_USER'])] final class UpdateUser { public function __construct(private readonly UserApi $users) {} public function __invoke(UpdateUserRequest $req, Context $ctx): UserResponse { return UserResponse::fromArray( $this->users->update($req->id, $req->toArray(), $ctx->user->getId()), ); } }
That's a full handler. No routing, no controllers, no manual validation, no container wiring — the bundle does the boring parts.
Documentation
| Chapter | Covers |
|---|---|
| Getting started | Install, first handler, first call |
| Methods | #[Rpc\Method], batch, notifications, deprecation |
| Parameters & DTOs | DTO denormalization, #[Rpc\Param], dates |
| Security & roles | roles, RoleMatch, security-core integration |
| Caching | #[Rpc\Cache], scopes, pools, tags, invalidator |
| Rate limiting | Four policies, three scopes |
| Streaming | NDJSON / SSE / JSON-array, error frames |
| MCP | Tool listing, invoke, formats, transformer |
| OpenRPC | Generate the spec |
| Errors | Exception hierarchy, custom server errors |
| Observability | Events, profiler, logging, Sentry, OpenTelemetry |
| CLI & maker | debug:rpc, rpc:cache:clear, make:rpc-method |
| Configuration reference | Every YAML knob |
| Context | The Context object, request id |
The same chapters are also served at
knetesin.github.io/json-rpc-server
once GitHub Pages is enabled on the docs/ folder.
Table of contents
- Why this bundle
- Requirements
- Install
- Five-minute tour
- Feature highlights
- Configuration
- Versioning
- Contributing
- License
Why this bundle
- Real Symfony, not glued on. Methods are services, DTOs go through Symfony Serializer, validation through Symfony Validator, authorisation through Symfony Security. No parallel universe to maintain.
- Attribute-driven.
#[Rpc\Method],#[Rpc\Cache],#[Rpc\RateLimit],#[Rpc\Stream],#[Rpc\Mcp]. One place to read, one place to grep. - Compile-time discovery. Every method is registered in a container compiler pass — zero reflection in the hot path, zero boot tax.
- First-class MCP. Expose handlers as MCP tools with auto-generated JSON
Schemas. Five rendering formats (
json,pretty_json,markdown,plain,toon) so the same tool can answer LLM agents and machine clients with shapes each prefers. - Streaming on its own endpoint. NDJSON, Server-Sent Events, JSON-array.
Spec-compliant
/rpcstays unchanged;/rpc/streamis the deliberate extension. - Built-in observability. Drop a flag in YAML and get PSR-3 logs, Symfony Web Profiler entries, Sentry breadcrumbs, or vendor-neutral OpenTelemetry traces + metrics + W3C trace-context propagation.
- Safe defaults. Handlers are non-shared (no state leak under RoadRunner / FrankenPHP / Swoole). DTOs reject unknown fields. Cache invalidation by tag. Per-method body-size limits. Deprecation headers.
Requirements
- PHP 8.3+ (typed class constants are used throughout)
- Symfony 7.x or 8.x
ext-jsonsymfony/expression-language(routeconditions for per-route enable flags)
Optional packages (everything degrades gracefully when absent — the container build fails loudly only if you reference a feature whose package is missing):
| Package | Enables |
|---|---|
symfony/security-bundle |
role checks, authenticated Context::$user, user-scoped rate limit / cache |
symfony/cache |
tag-aware cache invalidation (RpcCacheInvalidator::purgeMethod / purgeTags) |
symfony/rate-limiter |
#[Rpc\RateLimit] |
symfony/maker-bundle |
bin/console make:rpc-method scaffolder |
symfony/web-profiler-bundle |
RPC panel in the Symfony Web Profiler |
sentry/sentry-symfony |
Sentry breadcrumbs / tags / spans |
open-telemetry/sdk |
OpenTelemetry traces / metrics / propagation |
Install
composer require knetesin/json-rpc-server
With Symfony Flex the bundled recipe should create two files:
config/packages/json_rpc_server.yaml (settings) and
config/routes/json_rpc_server.yaml (route import — required for
debug:router to show /rpc). The recipe ships in the package (.symfony/recipe/);
if composer require did not copy them, see Getting started.
Without Flex (or if the recipe was skipped), add manually:
// config/bundles.php return [ // ... Knetesin\JsonRpcServerBundle\KnetesinJsonRpcServerBundle::class => ['all' => true], ];
# config/routes/json_rpc_server.yaml json_rpc_server: resource: '@KnetesinJsonRpcServerBundle/Resources/config/routes.php' type: php
# config/packages/json_rpc_server.yaml json_rpc_server: ~
That's it. Default routes:
| Route | Path | Method |
|---|---|---|
rpc |
/rpc |
POST |
rpc_stream |
/rpc/stream |
POST |
rpc_mcp_tools |
/mcp/tools |
GET |
rpc_mcp_call |
/mcp/call |
POST |
All paths configurable; any route disable-able via
json_rpc_server.routes.{name}.enabled: false.
Five-minute tour
A handler
// src/Rpc/Add.php use Knetesin\JsonRpcServerBundle\Attribute as Rpc; #[Rpc\Method('math.add', description: 'Add two integers.')] final class Add { public function __invoke(int $a, int $b): array { return ['sum' => $a + $b]; } }
curl -X POST http://localhost/rpc \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"math.add","params":{"a":2,"b":3},"id":1}'
{"jsonrpc":"2.0","result":{"sum":5},"id":1}
A DTO
final class UpdateUserRequest { public function __construct( #[Assert\Uuid] public string $id, #[Assert\Length(min: 2, max: 120)] public string $name, #[Assert\Email] public ?string $email = null, public ?Date $birthday = null, ) {} } #[Rpc\Method('user.update', roles: ['ROLE_USER'])] final class UpdateUser { public function __invoke(UpdateUserRequest $req, Context $ctx): UserResponse { /* … */ } }
Invalid input surfaces as -32602 Invalid params with per-field violation
paths in error.data. No try/catch in your handler.
Inspecting
bin/console debug:rpc
bin/console debug:rpc user.update --schema # JSON Schema of the DTO
Scaffolding (with symfony/maker-bundle)
bin/console make:rpc-method UserGetByEmail \
--method=user.getByEmail --with-dto --with-test
Feature highlights
DTOs and validation
DTOs are plain PHP classes. The bundle denormalizes incoming JSON via Symfony
Serializer (enums, dates, nested VOs, value objects with constructors —
everything), validates via Symfony Validator, and surfaces violations with
their field paths. #[Rpc\Param] is available for handlers that prefer
scalar parameters over a DTO.
Roles
#[Rpc\Method('admin.users.delete', roles: ['ROLE_ADMIN', 'ROLE_USER_ADMIN'])] #[Rpc\Method('billing.invoice.void', roles: [...], rolesMatch: RoleMatch::All)]
any (default) requires one of the roles; all requires every role. Public
methods omit roles.
Caching
#[Rpc\Method('feed.list')] #[Rpc\Cache(ttl: 60, scope: UserScope::class, tags: ['feed'])]
Cache key composed from method + scope contributor (user / IP / your own) +
hashed params. Notifications never cached. Tag-aware invalidation via
RpcCacheInvalidator when symfony/cache is installed.
Rate limiting
#[Rpc\Method('email.send')] #[Rpc\RateLimit(limit: 10, intervalSec: 60, scope: RateLimitScope::User)]
Four policies (FixedWindow, SlidingWindow, TokenBucket, NoLimit),
three scopes (User, Ip, GlobalScope). Excess calls throw
RateLimitExceededException (code -32003) with retryAfter in data.
Streaming
#[Rpc\Method('export.users')] #[Rpc\Stream(format: StreamFormat::Ndjson)] final class ExportUsers { public function __invoke(ExportRequest $req): \Generator { foreach ($this->repo->iterate($req->filters) as $row) { yield $row; } } }
POST the same JSON-RPC envelope to /rpc/stream. Three formats: Ndjson,
Sse, JsonArray. Mid-stream errors emit an inline error frame in the
active format instead of breaking the HTTP response.
MCP — for LLM agents
Two ways to expose methods as Model Context Protocol tools:
- Opt-in per method:
#[Rpc\Mcp(description: '…')] - Opt-out by prefix:
json_rpc_server.mcp.expose_all: true+exclude_prefixes: ['auth.']
GET /mcp/tools lists tools with auto-generated JSON Schemas built from the
DTO constructor and a curated set of Symfony Validator constraints
(NotBlank, Length, Range, Positive, Choice, Email, Url,
Regex). POST /mcp/call invokes them.
Five rendering formats — chosen per-request via header / query / attribute:
| Format | Output |
|---|---|
json (default) |
compact JSON, one line — smallest payload |
pretty_json |
indented JSON — chat UI |
markdown |
tables for lists, text for scalars, JSON for the rest |
plain |
scalars unquoted, objects pretty JSON |
toon |
TOON — indentation-based, token-efficient for LLM consumers |
Typed exceptions
final class QuotaExceededException extends RpcException { public function __construct(int $used, int $limit) { parent::__construct(sprintf('Quota exceeded: %d/%d', $used, $limit)); } public function rpcCode(): int { return -32010; } public function rpcData(): mixed { return ['retryAfter' => 60]; } } throw new QuotaExceededException($used, $limit);
Bundle-provided exceptions cover -32700 Parse, -32600 InvalidRequest,
-32601 MethodNotFound, -32602 InvalidParams, -32603 Internal,
-32001 AccessDenied, -32002 NotFound, -32003 RateLimitExceeded.
Context
public function __invoke(MyRequest $req, Context $ctx): MyResponse { // $ctx->methodName — 'user.update' // $ctx->requestId — X-Request-Id header or auto-generated // $ctx->user — Symfony security user (?UserInterface) // $ctx->roles — list<string> }
No Security::getUser() calls everywhere; the dispatcher hands you Context
when you ask for it.
Observability — pick your stack, all opt-in
| Stack | Switch |
|---|---|
| PSR-3 logging | json_rpc_server.logging.enabled: true |
| Symfony Web Profiler | auto-active in kernel.debug |
| Sentry (breadcrumbs / tag / spans) | json_rpc_server.sentry.enabled: true |
| OpenTelemetry (traces / metrics / propagation) | json_rpc_server.opentelemetry.enabled: true |
All four read the same three PSR-14 events the dispatcher fires
(MethodInvocationStarted/Completed/Failed), plus the streaming events.
Wire your own listener for anything custom.
OpenRPC document
OpenRpcDocumentBuilder generates an OpenRPC spec
of every registered method — feed it to SDK generators / Postman / docs sites.
Deprecation
#[Rpc\Method(deprecated: 'use user.v2.update instead')] — every call is
logged with the reason, and the response carries Deprecation: true (RFC
9745) plus the human-readable hint in the configurable
X-Rpc-Deprecated header. Deprecated methods auto-hidden from MCP.
Configuration
Every knob, all defaults shown. Place under
config/packages/json_rpc_server.yaml.
json_rpc_server: # ---------- security ---------- security: roles_match: any # default for methods without rolesMatch expose_role_names: true # AccessDenied messages name missing role(s) # ---------- request / response shape ---------- max_request_size: 1048576 # bytes; 0 disables. 1 MiB default max_json_depth: 32 # json_decode nesting limit json: encode_flags: 96 # bitmask of json_encode flags for responses # default 96 = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES # JSON_THROW_ON_ERROR is always OR-ed in by the bundle headers: deprecation: 'X-Rpc-Deprecated' # custom header carrying the deprecation reason context: request_id_header: 'X-Request-Id' # set '' to disable header lookup # ---------- params / DTOs ---------- params: allow_positional_dto: false # accept `params: [...]` for single-DTO handlers reject_unknown: true # DTO denormalization fails on unknown fields serializer: datetime_format: iso8601 # iso8601 | timestamp | timestamp_ms | any date() format date_format: 'Y-m-d' # Type\Date wire format timezone: ~ # null = keep source value timezone # ---------- handlers in DI ---------- handlers: public: false # whether handler services are public shared: false # safe for long-running runtimes; flip when stateless # ---------- routes (per-route enabled flag) ---------- routes: rpc: { path: /rpc, enabled: true } stream: { path: /rpc/stream, enabled: true } mcp_tools: { path: /mcp/tools, enabled: true } mcp_call: { path: /mcp/call, enabled: true } # ---------- caching ---------- cache: default_pool: cache.app pools: {} # { name: service.id } — referenced by #[Rpc\Cache(pool: 'name')] max_readable_key_length: 200 key_prefix: rpc.cache hash_prefix: rpc # ---------- rate limiter ---------- rate_limiter: cache_pool: cache.app # PSR-6 pool used as storage # ---------- streaming ---------- stream: headers: # set null to remove a default header X-Accel-Buffering: no Cache-Control: no-cache # ---------- profiler ---------- profiler: enabled: true # no-op outside kernel.debug # ---------- MCP ---------- mcp: enabled: true format_header: 'X-Mcp-Format' format_query: 'format' default_format: json # json | pretty_json | markdown | plain | toon apply_rate_limit: false # apply #[Rpc\RateLimit] on /mcp/call expose_all: false # every RPC method becomes an MCP tool unless excluded exclude_prefixes: [] exclude_methods: [] whitelist_methods: [] schema_max_depth: 6 # JsonSchemaBuilder recursion guard markdown: max_table_rows: 25 # above this `markdown` falls back to JSON max_table_cols: 6 # ---------- observability (all opt-in) ---------- logging: enabled: false channel: ~ # e.g. monolog.logger.rpc level_started: debug level_completed: info level_failed: warning log_params: true log_result: false slow_threshold_ms: ~ # escalates slow calls to level_failed sentry: enabled: false breadcrumbs: true tag_method: true transactions: false ignore_exceptions: [...] # default: standard client-side exceptions opentelemetry: enabled: false tracer_name: json-rpc traces: true metrics: true propagate_traceparent: true record_params: false record_result: false record_max_chars: 2048 stream: record_row_count: true span_per_row: false ignore_exceptions: [...] # default: standard client-side exceptions
Full reference with every knob's rationale: docs/en/13-configuration.md.
Versioning
Semantic Versioning. Anything outside the documented public API
(Knetesin\JsonRpcServerBundle\Attribute\*, Knetesin\JsonRpcServerBundle\Context\*,
Knetesin\JsonRpcServerBundle\Exception\*, Knetesin\JsonRpcServerBundle\Type\*, event classes,
configuration tree) is internal and may change in patch releases.
Contributing
git clone https://github.com/knetesin/json-rpc-server cd json-rpc-server composer install composer check # cs-check + phpstan + test
Pull requests welcome. Discussion / questions: GitHub Discussions. Bugs: issues.
For larger features, please open a discussion first — the bundle aims to stay small at the core and push everything else to opt-in subscribers.
License
MIT. © Contributors of
knetesin/json-rpc-server.