fguillot/json-rpc
A lightweight PHP implementation of JSON-RPC 2.0 for building clients and servers. It supports method calls, notifications, batch requests, and error handling, with a clean API for exposing PHP classes as remote services. Good for creating APIs or integrating with JSON-RPC services.
Install and bind once. The package is framework-agnostic — there is no service provider, facade, or config file shipped. Add it with Composer and wire it yourself:
# production: stay on the stable 1.x line
composer require fguillot/json-rpc:^1.0
# evaluating 2.0: branch only, it is still a preview
composer require fguillot/json-rpc:2.0.0-beta2
2.0.0-beta2 is the second preview cut from the 2.x branch (PR #29 is still open) and the notes say the API may still move before 2.0.0. Treat it as something you evaluate on a branch, not something you ship to production yet. When you do move, read UPGRADE-2.0.md first — it maps every v1 call to its v2 form and now lists the behaviour changes in full, including the one that will bite hardest: parameters are no longer coerced.
The two entry points are unchanged: JsonRPC\Client (talk to a remote endpoint) and JsonRPC\Server (expose your own endpoint). Everything else — Request, Response, ProcedureHandler, the exceptions, the transports — is supporting machinery you'll rarely touch directly.
Put connection details in config/services.php so they're env-driven like every other integration:
// config/services.php
'rpc' => [
'url' => env('RPC_URL', 'https://rpc.example.com'),
'timeout' => env('RPC_TIMEOUT', 10),
],
Bind a configured client in AppServiceProvider. This is the single most useful step — it keeps URLs and timeouts out of your call sites and gives you one place to swap in a fake during tests:
use JsonRPC\Client;
$this->app->singleton(Client::class, fn () => new Client(
config('services.rpc.url')
));
First client use case — a read from a remote service:
$result = app(Client::class)->call('user.get', ['id' => 42]);
That's the whole API surface for the common case: a procedure name and an array of params. Params can be positional ([42, 23]) or named (['id' => 42]), matching the JSON-RPC 2.0 spec. On 2.x, send the types the server declares — ["5", "3"] against function (int $a, int $b) is now -32602 Invalid params instead of being silently cast. Code that sends correctly typed params today upgrades without drama.
First server use case — expose a route. Register procedures, let the library read the JSON body, and return its output:
public function __invoke(Request $request)
{
$server = new \JsonRPC\Server();
$server->register('ping', fn () => 'pong');
$server->register('order.count', fn () => Order::count());
ob_start();
$server->execute($request->getContent());
$json = ob_get_clean();
return response($json, 200, ['Content-Type' => 'application/json']);
}
Where to look first when something misbehaves: Client::call() for request shaping, Server::register() / Server::execute() for dispatch, and Request/Response for how ids, params, and error codes are serialized. On 2.x also look at the transport layer (JsonRPC\HttpClient and its cURL / stream / PSR-18 implementations) — that is where most of the beta's changes landed. The README is short and accurate; the source is ~1k lines and readable in a sitting.
Wrap the client in a per-service gateway. Don't scatter app(Client::class)->call(...) across controllers. One class per upstream keeps procedure names, retries, and error mapping in one place:
class BillingRpc
{
public function __construct(private Client $client) {}
public function charge(int $invoiceId, int $cents): array
{
return $this->client->call('invoice.charge', [
'invoice_id' => $invoiceId,
'amount' => $cents,
]);
}
}
Push slow calls into jobs. JSON-RPC calls are network I/O and will block a request for the full timeout. For anything that isn't needed to render the response, dispatch a job with $tries and $backoff set. The library has no retry logic of its own — 2.x tightened transport correctness (short bodies, compression, chunking) but still hands every failure straight to you as an exception.
Use batching for fan-out reads. When you need N records from the same upstream, one round trip beats N:
$client = app(Client::class);
$client->batch();
foreach ($ids as $id) {
$client->call('user.get', ['id' => $id]);
}
$responses = $client->send(); // array of responses, correlated by id
Verify the exact method names against the installed version — batching has been through a couple of shape changes — but the pattern is always "queue calls, send once, correlate by id." On 2.x each entry in a batch is validated individually, so a single mismatched id or non-answer payload is refused per call rather than collapsing the whole batch into null.
Authenticate the server with filters, not inside each procedure. Server::before() registers a callback that runs ahead of a procedure, which is the right hook for token checks, rate limits, and audit logging:
$server->before('invoice.charge', function () {
abort_unless(auth()->check(), 401);
});
There is no prefix/wildcard matching built in, so if you have a namespace of admin methods, write a small helper that registers the filter for each method as you register it. Belt-and-braces it with Laravel route middleware for the actual HTTP layer.
Return Class::method callables to keep controllers thin. register() takes any PHP callable, so [$this->orders, 'count'] or an invokable action class works fine and lets you resolve dependencies normally. Two 2.x server fixes worth knowing about: variadic procedures now accept named parameters, and a procedure whose name is numeric can be registered through the array helpers — both were silent dead ends before.
Fake the client in tests, not the network. The library uses its own transports rather than Laravel's HTTP client, so Http::fake() will not intercept anything. Bind a subclass that records calls and returns canned responses:
class FakeRpcClient extends Client
{
public array $calls = [];
public array $responses = [];
public function call($procedure, array $params = [])
{
$this->calls[] = compact('procedure', 'params');
return $this->responses[$procedure] ?? null;
}
}
$this->app->instance(Client::class, $fake = new FakeRpcClient());
// ... act ...
$this->assertSame('invoice.charge', $fake->calls[0]['procedure']);
If instead you fake lower down — stubbing the transport and feeding raw JSON — remember 2.x validates every answer before returning it: a body carrying another request's id, or JSON that isn't an answer at all, now throws rather than surfacing as a null result. Your fixtures must echo the request id back, in batches too.
Log the wire format with a decorator. Neither Client nor Server emits Laravel events. Wrap the client (same interface, delegate inside) and log procedure, params, duration, and response — invaluable when an upstream starts returning -32603 under load.
Server::execute() echoes. It writes the JSON response to the output buffer rather than returning a string, which is why the controller example uses ob_start(). Forgetting this produces a response with the JSON buried inside (or duplicated output), and the Content-Type header will be whatever Laravel guessed.
JSON-RPC errors arrive with HTTP 200. A transport-successful call can still carry {"error": {"code": -32602, ...}}. The client throws on error responses (JsonRPC\Exception\ResponseException and connection-failure variants), so wrap calls in try/catch and translate into domain exceptions — don't just let a -32603 bubble up as a 500 with no context.
2.x changed which exception a bad status produces. An error object relayed under a non-2xx status — 500 included — is now passed through rather than replaced by the status code, so code, message and data survive and you catch ResponseException with the real detail. ServerErrorException is now reserved for a 500 carrying something that isn't a JSON-RPC error, e.g. a gateway error page. 3xx, 401, 403 and 404 are still reported as-is. If you have catch (ServerErrorException $e) blocks that assume "500 means upstream broke", audit them before upgrading.
php://input and Laravel. execute() with no argument reads the raw body from php://input. That usually works but is fragile under Octane and any middleware that has already consumed the stream. Pass $request->getContent() explicitly, as above.
Set the timeout explicitly. The default can hang far longer than a web request should. Laravel's global HTTP timeout config does not apply; configure it on the client/transport directly.
Http::fake() and Http::retry() still don't apply — but PSR-18 does. Anything you rely on from Laravel's HTTP layer (recorded requests, assertSent, config/http.php SSL options) must be reimplemented. That said, 2.x ships a PSR-18 transport alongside cURL and stream, so you can inject any PSR-18 client — Guzzle included — if you want its middleware, proxies or TLS settings. That injection point plus JsonRPC\HttpClient remain the main extension points in the package. Note that Laravel's own HTTP client isn't a PSR-18 ClientInterface, so Http::fake() still won't intercept your RPC traffic.
Compression false positives are fixed — don't "fix" them again. If you previously saw a gzip-enabled upstream reported as a malformed payload, that was a client bug, not your params. beta2 decides on the bytes rather than on a Content-Encoding header that a transparently-decompressing client leaves behind, and a decoded page that simply isn't JSON is no longer blamed on compression. Conversely, a body shorter than its announced Content-Length now raises ConnectionFailureException on stream and PSR-7 transports like the cURL one, instead of a bare RuntimeException — update catch blocks that expected the generic exception.
Free second per request. The cURL transport no longer lets libcurl add Expect: 100-continue, which cost roughly a full second on every request body above a megabyte. If you push large payloads and stayed on 1.x for performance reasons, this is a concrete win.
Cookies are validated now. A cookie value carrying a control character or a separator — whether the server set it or your application did — is refused, and a cookie deleted with an empty value, Max-Age=0 or a past Expires is forgotten instead of being resent forever. If you rely on cookie-based stickiness across calls, re-test session flows after upgrading; a stale jar that used to "work" may now be pruned.
Keep parameter names stable. The server-side procedure handler uses reflection to map named params onto your callable's signature, so renaming $invoiceId to $id silently breaks callers who send named params. If you want to be immune, take a single array $params and validate it yourself. On 2.x this matters more, not less, because there is no coercion fallback to paper over a mismatch.
Register an allowlist, never a router. There is no built-in authorization, no method discovery protection, and no per-method scoping beyond the before filters you write. Every registered procedure is callable by anyone who can reach the endpoint. If you forward Authorization, note that the server's authentication-header helper now accepts the full Basic <base64> form rather than only a bare base64 value — if you hand-rolled header parsing, drop it.
Notifications (requests without an id) must not receive a response. The library handles this for you when it dispatches, but if you build request/response objects manually or add custom middleware, preserve null ids or batch clients will hang waiting for a reply.
Version pin and be ready to vendor. The canonical package (fguillot/json-rpc) is popular and stable; this fork has a small audience (23 stars) and a thin release cadence, and the 2.0 line is still a beta. It's MIT, so pin a version in composer.json, and if you hit a bug you'll likely fix it in a fork rather than wait for upstream. Read the changelog between minor versions — the batch and middleware APIs have shifted across releases. On the plus side, the 2.x branch is unusually well guarded (366 tests, 100% line coverage enforced in CI, PHPStan at max level with no baseline, phpcs clean, transports exercised against real HTTP servers rather than mocks) and is CI'd on PHP 8.4 and 8.5 — check the composer.json platform constraint before assuming it installs on older runtimes.
Debugging tip: when a call fails with a parse error, dump the exact JSON body your client produced (json_encode of the request) before blaming the server. Malformed params — a PHP array that encodes as a JSON object instead of an array, or vice versa — account for most -32600/-32602 responses. On 2.x, add "the right types, not just the right shape" to that checklist, since coercion is gone.
How can I help you explore Laravel packages today?