nanofelis/json-rpc-bundle
Symfony bundle for building JSON-RPC 2.0 APIs. Expose services as RPC methods, handle requests and errors consistently, and integrate JSON-RPC endpoints with Symfony routing and dependency injection.
Reality check before you type composer require: this is a Symfony bundle, not a Laravel package. It ships a Bundle class, a container extension, a compiler pass, and a route loaded through Symfony's routing/DI/HttpKernel stack. Dropping it into a Laravel app gives you the classes and a pile of Symfony dependencies, but nothing wires up — no service provider, no route, no container tags for the parameter converters. So there are two legitimate ways a Laravel developer "uses" this:
6.0 raises the floor to PHP >= 8.4 and Symfony 6.4 / 7 / 8 (Symfony 5 dropped). Check your runtime and your host's PHP before anything else — a lot of Laravel apps are still on 8.2/8.3, and this package can no longer live in the same monorepo runtime as them. If you're upgrading an existing 5.x integration, read docs/migration.md (5.x → 6.0) and the CHANGELOG first; several changes are silent at the HTTP level and only bite your client.
Steps for the Symfony side (the primary scenario):
composer require nanofelis/json-rpc-bundle
# Flex registers Nanofelis\JsonRpcBundle\NanofelisJsonRpcBundle in config/bundles.php
In 6.0 #[JsonRpcService] is autoconfigured — the nanofelis_json_rpc tag no longer has to be applied by hand, so annotating a service is sufficient to publish it over HTTP. That's convenient and also the first thing to audit: annotate only what you intend to expose.
namespace App\Rpc;
use Nanofelis\JsonRpcBundle\Attribute\JsonRpcService;
use Nanofelis\JsonRpcBundle\Attribute\RpcMethod;
#[JsonRpcService('user')]
class UserApi
{
#[RpcMethod]
public function get(int $id, UserRepository $repo): array
{
return $repo->findOrFail($id)->toArray();
}
}
The bundle registers a single POST endpoint (default /rpc, configurable) and maps user.get → UserApi::get. Method keys are resolved at container build time by a compiler pass and exposed through a service locator, so only the service a request names is instantiated. Smoke-test before writing any client code:
curl -s -X POST https://app.test/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"user.get","params":{"id":1},"id":1}' | jq
Where to look first, in order:
docs/migration.md and CHANGELOG.md — you are on a package whose major releases rewrite semantics; these are load-bearing.Nanofelis\JsonRpcBundle\DependencyInjection\Configuration — the authoritative list of config keys, including max_batch_size. Trust this over the README when they disagree.RpcError and its built-in subclasses — your entire error contract lives there.First use case worth doing: collapse a handful of thin REST endpoints used only by your own frontend (dashboard reads, admin actions) into one POST /rpc surface. You get batching, consistent error envelopes, and typed params for free, and you stop bikeshedding resource URLs.
Keep RPC classes thin. The mapped class is an entry point, not a service layer. Auth checks and param validation live there, then hand off to real domain services. 6.0 removes the worst reason to be paranoid here — only the addressed service is constructed per request, instead of every tagged service and its whole dependency graph — but the classes are still container-shared. Under FrankenPHP or RoadRunner, never store per-request state on them.
Method naming as your API contract. domain.action (user.get, report.export) with a v1. prefix when you need versioning. Versioning via method name beats versioning via route, because a single route + firewall means you cannot version the endpoint itself.
DTO params, now through the standard resolver stack. 6.0 runs RPC methods through Symfony's normal controller argument lifecycle, so #[MapRequestPayload] and custom argument resolvers work. That makes the highest-value pattern cleaner:
#[RpcMethod]
public function create(#[MapRequestPayload] CreateUserInput $input): array
A validation failure becomes a clean InvalidParams (-32602) instead of a 500. This is the pattern to copy into Laravel: Route::post + a resolver that reflects the parameter type and hands off to $request->validate() or Validator::make(...).
Errors are a product decision, not an implementation detail. Define your own subclasses of RpcError and keep a table of codes:
final class UserNotFound extends RpcError
{
public function __construct(int $id)
{
parent::__construct(sprintf('User %d not found', $id), 1001);
}
}
Translate domain exceptions in the RPC layer — don't leak Doctrine or Laravel exceptions outward. Codes in -32000..-32768 are reserved; put yours at 1000+.
Batching is now per-entry and unordered. 6.0 fixes the biggest footgun: one malformed entry no longer discards the whole batch — each entry gets its own response and valid siblings still run. Two consequences you must code for:
id, never on array position.result and error members. Your client must reconcile per entry rather than treat the whole response as success or failure.Set max_batch_size deliberately. Default is 100; 0 disables the cap. Oversized batches get -32600. If you have an existing bulk/dashboard caller sending more than 100 calls, it starts failing after the upgrade with no obvious link to the release.
Do not batch writes. There is still no transaction across batch members, and with 6.0's per-entry isolation a partial failure is more likely to be visible as a mixed array, not less. Dashboards issuing four independent reads are the ideal batch; mutations stay one-call-per-request.
Notifications (id omitted) for fire-and-forget telemetry and audit pings. The response body is empty; clients that assume JSON will break.
Auth lives in two places — and 6.0 makes the second one practical. The HTTP firewall secures the route; per-method authorization must be enforced inside each RPC method (voters/policies), because one route means route-level rules can't distinguish user.get from user.delete. For cross-cutting auth/logging, listen to kernel.controller_arguments (this replaces the removed RpcBeforeMethodEvent), which now receives a duplicate of the real request — headers, cookies, session, client IP, query string, route attributes — and no longer masquerades as a sub-request, so listeners that skip sub-requests (often where auth lives) actually run. You also get resolved, typed arguments instead of raw params.
Laravel calling the Symfony service. Wrap it once, and correlate batches by id:
final class RpcClient
{
public function call(string $method, array $params = []): mixed
{
$res = Http::post(config('services.rpc.url'), [
'jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => Str::uuid()->toString(),
])->throw(); // transport-level only
$body = $res->json();
if (isset($body['error'])) {
throw RpcException::fromEnvelope($body['error']); // map code -> your exception classes
}
return $body['result'];
}
/** @param array<string, array{method: string, params?: array}> $calls keyed by your own id */
public function batch(array $calls): array
{
$payload = [];
foreach ($calls as $id => $call) {
$payload[] = ['jsonrpc' => '2.0', 'method' => $call['method'], 'params' => $call['params'] ?? [], 'id' => $id];
}
$body = Http::post(config('services.rpc.url'), $payload)->throw()->json();
// A rejected batch (empty, oversized, unparseable) comes back as a single object, not an array.
$body = array_is_list($body) ? $body : [$body];
$out = [];
foreach ($body as $entry) {
$out[$entry['id']] = isset($entry['error'])
? RpcException::fromEnvelope($entry['error'])
: $entry['result'];
}
return $out; // caller inspects each entry
}
}
Do envelope/ID handling and error-code mapping in exactly one place, and mirror the code table in both codebases or you'll spend evenings diffing constants.
Porting the pattern to Laravel (the realistic path if you don't want a Symfony service): one POST /rpc controller, a method => [class, method] map in config/rpc.php, app()->make($class) + reflection-based parameter binding, RpcError-style exceptions rendered by Handler::renderable, a max_batch_size check in middleware or a FormRequest, a try/catch per batch entry so one bad call doesn't poison the array, and $this->postJson('/rpc', [...]) in feature tests. The whole thing is ~150–200 lines and gives you the same contract without the HttpKernel dependency.
symfony/twig-bundle and ext-dom requirements — require them explicitly if you were relying on them. In a pure Laravel app this is dead weight with nothing bootstrapping it. Decide up front: separate service, or reimplement.Http::throw()/status-based retry logic is blind to application errors — always inspect error.code. (6.0 does clean up several cases that used to surface as 500s: non-conforming id values like floats or arrays now answer -32600, and a missing jsonrpc key no longer emits an undefined-key warning.)Content-Type: application/json is mandatory. Proxies and some clients drop it on POST and you get a bare InvalidRequest (-32600) with no hint why. If a call works in Postman but not from your app, check this first.-32601. This closes a real hole (services exposing __construct and letting a caller re-run it on a container-shared instance), but it means invokable services relying on __invoke/__call silently stop working. Audit your tagged services on upgrade.MethodNotFound (-32601) after a deploy is almost always stale container cache — bin/console cache:clear or your warmup step. Put it in the deploy script, not a runbook. Two new build-time failures to expect: duplicate #[JsonRpcService] keys (previously last-wins) and a service tagged without the attribute.[] now returns -32600 instead of []. Results may be unordered. Oversized batches are rejected. Any 5.x client that indexed into results[0] or assumed homogeneous success is broken by 6.0 — add tests for all three before upgrading.max_batch_size caps the count, but pair it with proxy limits (client_max_body_size) and a per-method timeout budget — otherwise one client can pin a worker for the whole batch.data and stack traces. In debug mode these can carry exception messages and internal class names; denormalization failures in particular leak App\Dto\.... Sanitize per environment, and assert on code, not message, in tests so you don't accidentally encode internal text into your contract.ValueResolver — now first-class, for e.g. resolving IDs to entities or pulling a caller identity off the request; (2) custom RpcError subclasses via the error factory; (3) a kernel.controller_arguments listener for logging, metrics, and per-method rate limiting — the cheapest place to add all three; (4) a decorator around the server if you need to wrap the whole call. Environment-gate internal services by only importing/annotating them in dev config — remember the attribute alone now publishes a service.KernelBrowser) with a raw JSON body; assert result shape and error.code. Cover the paths that regress silently: parse error, unknown method, invalid params, magic-method call, empty batch, oversized batch, malformed id, and a batch containing exactly one bad entry alongside good ones.composer.json, keep your RPC classes free of bundle-specific types as far as possible (only the attributes and RpcError should appear), and keep a one-page internal doc of the method map, error codes, and the batch/client contract. If you ever port it to Laravel, the migration is then mechanical rather than archaeological.How can I help you explore Laravel packages today?