02 — Methods
A method is a class that:
- Carries
#[Rpc\Method('name')] - Defines
__invoke()— anything callable, anything serializable returned
The compiler pass discovers them via auto-configuration. No manual service registration, no central method registry to keep in sync.
The attribute
#[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
outputSchema: UserDto::class, // optional: see MCP / OpenRPC chapters
)]
All fields beyond name are optional. null-valued ones fall back to bundle
defaults (e.g. params.allow_positional_dto).
The handler
#[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.
Return values
The dispatcher normalizes return values through Symfony’s SerializerInterface
before they reach the client. This means handlers can return:
- Plain arrays / scalars / nulls — returned as-is
- DTOs — denormalized into arrays using the configured normalizers
- Doctrine entities — serialized per your serialization groups
- Anything implementing
JsonSerializable
The 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.
Batch requests
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.
Batches are processed sequentially by default
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.
Opt-in: parallel batches via loopback fan-out
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
self_url: 'http://127.0.0.1/rpc' # required when enabled
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
Parallel batch is off by default. Turning it on requires self_url — the
absolute http:// / https:// URL of this server’s RPC endpoint that
sub-calls are POSTed to; the container build fails with a clear message when
it is missing or not an absolute http(s) URL. The URL is never derived from
the incoming request: the Host header is client-controlled, and sub-calls
carry the forwarded Authorization header, so a forged Host would send the
caller’s credentials to a server of the attacker’s choosing.
Sub-calls re-send the headers listed in parallel_batch.forward_headers
(default: Authorization, X-Request-Id, X-Forwarded-For,
X-Forwarded-Proto, traceparent, tracestate). Cookie is not
forwarded by default: with PHP’s native (locking) session handler the parent
request holds the session lock while its sub-calls wait for it, so every
sub-call hangs until timeout. Apps that authenticate by session cookie add
Cookie to the list explicitly, and only with non-locking or read-only
sessions.
Each sub-call also carries the original client IP, signed by the parent
(X-Rpc-Fanout-Client-Ip + X-Rpc-Fanout-Signature, an HMAC-SHA256 keyed
from kernel.secret over the IP, the fan-out depth and the exact sub-call
body). RateLimitScope::Ip, the per-IP guest bucket of RateLimitScope::User
and the IpScope cache scope use it, so every client keeps its own bucket
instead of all sharing the server’s loopback address. Headers that don’t
verify are ignored. A non-empty kernel.secret (framework.secret) is
required while parallel batch is on. In your own code, read the client IP via
Knetesin\JsonRpcServerBundle\Http\ClientIpResolver::clientIp() rather than
Request::getClientIp(), which returns the loopback address inside a
sub-call.
A notification inside a fanned-out batch never produces a response entry, whatever the sub-call replies.
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:
- Per-batch
max_concurrencycap. - System-wide
budgetvia APCu — never more than N sub-calls in flight across all parents. - Recursion guard (
X-Rpc-Fanout-Depthheader) — sub-calls can’t fan out again. - Per-sub-call timeout fallback — a stuck sub-call becomes one error, not a stuck batch.
- Recommended deployment: dedicated worker pool for fan-out
(
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). With
budget_store: apcu (the default) the tracker checks APCu at runtime, in the
PHP process that serves the request — not while the container is built, so a
cache:warmup run by a CLI without APCu does not matter. When APCu is missing,
disabled (apc.enabled=0, or apc.enable_cli=0 on CLI) or refuses the
write, the budget reservation fails and the batch runs sequentially —
never uncapped — and a warning is logged once per process. To fan out without
a system-wide cap on purpose, set budget_store: null explicitly.
Notifications
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.
Deprecation
#[Rpc\Method('user.legacy_get', deprecated: 'Use user.get instead.')]
Effects:
- Logger. Every call emits a
warningwithmethodandreason. - HTTP headers. Responses carry
Deprecation: trueandX-Rpc-Deprecated: user.legacy_get: Use user.get instead. - MCP. Deprecated methods are hidden from
/mcp/toolsunless explicitly whitelisted — LLM agents shouldn’t pick them up as fresh tools. - OpenRPC. Method emitted with
"deprecated": trueand a customx-deprecation-reasonfield.
Common patterns
Public method (no roles)
#[Rpc\Method('public.ping')]
final class Ping
{
public function __invoke(): array { return ['pong' => true]; }
}
Protected method
#[Rpc\Method('user.delete', roles: ['ROLE_ADMIN'])]
final class DeleteUser { /* … */ }
See Security & roles.
Method that needs the raw HTTP request
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.
Method that needs the JSON-RPC envelope
public function __invoke(RpcRequest $req, Context $ctx): array
{
// $req->id, $req->method, $req->params, $req->isNotification
}
Knetesin\JsonRpcServerBundle\Request\RpcRequest is also injectable.
One method, multiple shapes
JSON-RPC method names form a flat namespace. Use prefixes for grouping:
#[Rpc\Method('user.get')]
#[Rpc\Method('user.update')]
#[Rpc\Method('user.delete')]
Method names must not contain % — the container would read it as a
%parameter% placeholder; the build fails with a LogicException.
Versioning works the same way — see the OpenRPC chapter.