A method is a class that:
#[Rpc\Method('name')]__invoke() — anything callable, anything serializable returnedThe compiler pass discovers them via auto-configuration. No manual service registration, no central method registry to keep in sync.
#[Rpc\Method(
name: 'user.getByEmail',
roles: ['ROLE_USER'], // see Security chapter
rolesMatch: RoleMatch::Any, // any | all
allowPositionalDto: false, // see Parameters chapter
rejectUnknown: true, // see Parameters chapter
deprecated: 'Use user.find instead.', // marks as deprecated
description: 'Looks up a user by email.', // human-readable; used in MCP
)]
All fields beyond name are optional. null-valued ones fall back to bundle
defaults (e.g. params.allow_positional_dto).
#[Rpc\Method('user.getByEmail')]
final class GetUserByEmail
{
public function __construct(
// Inject services like any other Symfony service.
private readonly UserRepository $users,
) {}
/** @return array<string, mixed> */
public function __invoke(GetUserByEmailRequest $req, Context $ctx): array
{
$user = $this->users->findOneByEmail($req->email)
?? throw new NotFoundException("No user for {$req->email}");
return [
'id' => $user->getId(),
'email' => $user->getEmail(),
];
}
}
Handlers are non-shared services — every request gets a fresh instance. Safe for long-running workers (RoadRunner, Octane, Swoole): no leaked state between users.
The dispatcher normalizes return values through Symfony’s SerializerInterface
before they reach the client. This means handlers can return:
JsonSerializableThe normalized form is also what gets cached and what events carry, so listeners see the same shape regardless of whether a hit came from cache or from a fresh call.
Skip normalization for streaming methods — see Streaming.
The /rpc endpoint accepts both single objects and arrays of objects per the
JSON-RPC 2.0 spec:
[
{"jsonrpc":"2.0","method":"math.add","params":[1,2],"id":1},
{"jsonrpc":"2.0","method":"math.add","params":[3,4],"id":2}
]
Returns an array of responses in the same order. Notifications (entries without
id) are processed but don’t produce response entries. If every entry is a
notification, the HTTP response is 204 No Content.
The dispatcher walks the array in order and runs each item to completion before
the next one starts — all in one PHP process. A batch of N items finishes in
roughly sum of per-handler durations, not max. Batches save the network
overhead (one HTTP request, one parser pass, one auth check), not handler time.
For real concurrency, clients should fire N separate HTTP requests in parallel
(e.g. json-rpc-client’s callAsync)
— PHP-FPM / RoadRunner / Swoole then dispatch each request to a different
worker and the calls run truly in parallel.
Per JSON-RPC 2.0 §6, a server may process a batch in any order and with any parallelism. The bundle ships an opt-in implementation that does exactly that by sending each batch item back to itself as a separate HTTP request — the worker pool then runs handlers in parallel.
json_rpc_server:
parallel_batch:
enabled: true # off by default
max_concurrency: 3 # max parallel sub-calls per batch
budget: 10 # system-wide cap (APCu-backed)
max_depth: 1 # no fan-out from a sub-call
connect_timeout: 0.5
timeout: 10
self_url: ~ # null = derive from the incoming request
Real operational risk. A naive setup can starve your worker pool. The bundle ships five safety layers to mitigate, but measure first before enabling in production:
max_concurrency cap.budget via APCu — never more than N sub-calls in flight
across all parents.X-Rpc-Fanout-Depth header) — sub-calls can’t fan out
again.self_url: 'http://127.0.0.1/internal/rpc-fanout') so fan-out can’t
exhaust the client-traffic pool.When fan-out can’t proceed (HttpClient missing, APCu missing, batch too small,
depth limit reached, budget exhausted), the controller transparently falls
back to sequential processing — clients see no difference except possibly
higher latency on that one batch. The BatchDispatchedEvent carries the
decision label (visible in the Web Profiler and OpenTelemetry traces) so you
can monitor exactly when fallback is firing.
Requires symfony/http-client (hard) and ext-apcu (soft). When
parallel_batch.enabled: true and budget_store: apcu (the default) but
APCu isn’t loaded, the bundle falls back to NullBudgetTracker and emits
an E_USER_WARNING at container build time — the system-wide budget is
off in that mode, so on FPM you risk pool exhaustion under load. To
silence the warning when you intentionally don’t want a global cap, set
budget_store: null explicitly.
A request without id is a notification:
{"jsonrpc":"2.0","method":"audit.log","params":{"event":"login"}}
The handler runs, no response body is sent, the HTTP response is 204. Even
if the handler throws, no error envelope is returned (per spec). The exception
is still logged and dispatched as a MethodInvocationFailedEvent.
Notifications are not cached even if the method has #[Rpc\Cache] — they
typically carry side effects you want to re-apply each time.
#[Rpc\Method('user.legacy_get', deprecated: 'Use user.get instead.')]
Effects:
warning with method and reason.Deprecation: true and
X-Rpc-Deprecated: user.legacy_get: Use user.get instead./mcp/tools unless explicitly
whitelisted — LLM agents shouldn’t pick them up as fresh tools."deprecated": true and a custom
x-deprecation-reason field.#[Rpc\Method('public.ping')]
final class Ping
{
public function __invoke(): array { return ['pong' => true]; }
}
#[Rpc\Method('user.delete', roles: ['ROLE_ADMIN'])]
final class DeleteUser { /* … */ }
See Security & roles.
public function __invoke(Request $request, Context $ctx): array
{
$ip = $request->getClientIp();
// …
}
Symfony\Component\HttpFoundation\Request is recognized as an injectable
parameter — the bundle wires it from RequestStack.
public function __invoke(RpcRequest $req, Context $ctx): array
{
// $req->id, $req->method, $req->params, $req->isNotification
}
Knetesin\JsonRpcServerBundle\Request\RpcRequest is also injectable.
JSON-RPC method names form a flat namespace. Use prefixes for grouping:
#[Rpc\Method('user.get')]
#[Rpc\Method('user.update')]
#[Rpc\Method('user.delete')]
Versioning works the same way — see the OpenRPC chapter.