sajya/client
A lightweight JSON-RPC client for Laravel and PHP that provides an expressive API for calling remote RPC methods and handling structured responses, making it easy to integrate external JSON-RPC services into your application.
sajya/client is the client-side counterpart to sajya/server: a small JSON-RPC 2.0 client built on top of Laravel's HTTP client. Reach for it when you are calling a JSON-RPC endpoint (usually another Laravel service using sajya/server) instead of hand-rolling Http::post() payloads.
Minimal setup:
composer require sajya/client
Publish the config if you need to change defaults or register named endpoints:
php artisan vendor:publish --tag=client-config
The first use case to get working is a single procedure call against an endpoint you control:
use Sajya\Client\Client;
$client = new Client('https://billing.internal/rpc');
$response = $client->call('math@sum', [1, 2]);
Where to look first:
config/client.php (after publishing) — endpoint URLs, default headers, timeouts.Sajya\Client\Client class — this is the whole API surface; it is small, read it end to end before building abstractions on top of it.sajya/server docs for the procedure naming convention (namespace@method) and parameter binding, since the client is only half the contract.Release note — 1.5.0: this release only widens the illuminate/http and illuminate/support version constraints (plus CI housekeeping). There are no API changes, so it is a safe drop-in upgrade and mainly matters because it restores compatibility with newer Laravel releases. Check your composer.json constraints if you were previously pinned to an older Laravel line.
Bind a configured client, don't new it everywhere. Constructing new Client($url) in every controller makes tests and multi-endpoint setups painful. Register a named instance in a service provider:
$this->app->singleton(BillingClient::class, fn () => new Client(
config('services.billing.rpc_url'),
));
Then inject BillingClient (or a thin wrapper around Client) wherever needed.
Wrap procedures in typed methods. JSON-RPC calls are stringly-typed by nature; a small wrapper class per remote service keeps procedure names and payload shapes in one place:
class BillingClient
{
public function __construct(private Client $client) {}
public function charge(int $invoiceId, int $amount): array
{
return $this->client->call('invoices@charge', [
'invoice_id' => $invoiceId,
'amount' => $amount,
]);
}
}
This gives you IDE completion, a single place to update when the remote procedure signature changes, and an obvious seam for Http::fake() in tests.
Positional vs named params matters. JSON-RPC 2.0 accepts both a list (positional) and an object (named). [1, 2] and ['a' => 1, 'b' => 2] are not interchangeable on the server side — match whatever the server's procedure signature expects, and be consistent within a service wrapper.
Batch related calls. If your version supports it, batching amortises the round trip when a page needs several independent lookups. Keep batch members independent: a single failing member should not invalidate the whole batch's usefulness.
Fire-and-forget notifications. For audit/log pings where you do not need a result, send without an id (JSON-RPC notification semantics) so the server does not build a response.
Auth and transport config. Most internal RPC endpoints are behind a token or mTLS. Prefer setting default headers/timeouts once (config or a constructor argument) over per-call plumbing. If the endpoint is flaky, wrap calls with retry logic — but only for idempotent procedures; retrying invoices@charge blindly is how you double-bill customers.
Testing with Http::fake(). Because the client rides on Laravel's HTTP client, you can assert the outgoing JSON-RPC envelope without a live server:
Http::fake(['*/rpc' => Http::response([
'jsonrpc' => '2.0', 'id' => 1, 'result' => 3,
])]);
$this->post('/checkout')->assertOk();
Http::assertSent(fn ($request) => $request['method'] === 'math@sum');
HTTP 200 does not mean success. JSON-RPC errors are returned in the response body (error.code, error.message, error.data) with a 200 status. Never branch on $response->successful() alone — inspect the envelope. Wrap the client so callers get a Result/exception, not a raw array they might misread.
Transport errors are a different beast. A 500, timeout, or connection refusal is not a JSON-RPC error; it means you never reached the RPC layer. Handle both classes separately, otherwise a downed service looks like a validation failure.
ID correlation in batches. Batch responses can come back in any order. If you correlate by array index you will eventually swap results; correlate by id.
Don't rely on status codes for validation. Server-side sajya/server validation failures surface as JSON-RPC error objects with structured data, not 422s. Parse error.data if you want to map them to form errors.
Keep secrets out of published config. Put endpoint URLs in config/services.php/config/client.php but read tokens from .env. Published config files are committed.
Debug by logging the raw exchange. When a procedure "returns nothing", log the raw request and response bodies (Laravel's Http::withOptions(['debug' => ...]) or a temporary Http::fake() dump). Nine times out of ten the issue is a mismatched procedure name (math@sum vs math.sum) or a params shape mismatch, both visible only in the raw envelope.
Extension points. The Client class is intentionally thin — subclass or decorate it rather than patching vendor code. A decorator that adds logging, metrics, retries, and error normalisation is the natural place for cross-cutting concerns, keeping the underlying client untouched across upgrades (relevant here, since 1.5.0 was a constraint-only release and future patches are likely to stay small).
Upgrade note. 1.5.0 is a dependency-range bump; run composer update sajya/client and your existing test suite is the right safety net. There is nothing to migrate, but verify your lockfile actually moved the illuminate/* constraints if you were blocked on an older Laravel version.
How can I help you explore Laravel packages today?