The Horde\Rpc\JsonRpc namespace provides a modern JSON-RPC implementation
supporting both JSON-RPC 1.1 and 2.0 protocols. It is built on top of
PSR-7 (HTTP Messages), PSR-14 (Event Dispatcher), PSR-15 (Server Handlers),
PSR-17 (HTTP Factories), and PSR-18 (HTTP Client).
use Horde\Rpc\JsonRpc\JsonRpcClient;
$client = new JsonRpcClient('https://api.example.com/rpc');
// Single call
$result = $client->call('math.add', [3, 4]);
// => 7
// Notification (fire-and-forget, no response)
$client->notify('log.event', ['user logged in']);
// Batch (JSON-RPC 2.0 only)
$results = $client->batch([
['method' => 'math.add', 'params' => [1, 2]],
['method' => 'math.multiply', 'params' => [3, 4]],
['method' => 'log.event', 'params' => ['batch started'], 'notification' => true],
]);By default, JsonRpcClient uses Horde\Http\Client\Curl as the HTTP
transport. You can inject any PSR-18 compliant client:
use Horde\Rpc\JsonRpc\JsonRpcClient;
// Guzzle, Symfony HttpClient, or any PSR-18 implementation
$client = new JsonRpcClient(
'https://api.example.com/rpc',
$guzzleClient,
);use Horde\Rpc\JsonRpc\JsonRpcClient;
use Horde\Rpc\JsonRpc\Protocol\Version;
$client = new JsonRpcClient(
'https://legacy.example.com/rpc',
version: Version::V1_1,
);An API provider tells the dispatcher which methods are available and how to
call them. Implement ApiProviderInterface (method registry) and
MethodInvokerInterface (method execution).
The simplest approach is CallableMapProvider -- a name-to-callable map:
use Horde\Rpc\JsonRpc\Dispatch\CallableMapProvider;
$provider = new CallableMapProvider([
'ping' => fn(): string => 'pong',
'math.add' => fn(float $a, float $b): float => $a + $b,
'user.get' => fn(int $id): array => $userRepository->find($id),
]);Use the JsonRpcHandler facade to wire everything together:
use Horde\Http\ResponseFactory;
use Horde\Http\StreamFactory;
use Horde\Rpc\JsonRpc\JsonRpcHandler;
$handler = new JsonRpcHandler(
provider: $provider, // ApiProviderInterface
invoker: $provider, // MethodInvokerInterface (same object)
responseFactory: new ResponseFactory(),
streamFactory: new StreamFactory(),
eventDispatcher: $eventDispatcher, // PSR-14 EventDispatcherInterface
);The handler implements both RequestHandlerInterface and
MiddlewareInterface, so the same object works for direct routing
and in a middleware stack.
As a PSR-15 RequestHandler (direct routing):
// $request is a PSR-7 ServerRequestInterface
$response = $handler->getHandler()->handle($request);As a PSR-15 Middleware (in a middleware stack):
// The handler auto-detects JSON-RPC requests by path and content type
$app->pipe($handler->getHandler());The handler matches POST requests to the configured path with a JSON
content type. Non-matching requests pass through to the next handler.
For simple APIs, pass a map of method names to callables:
use Horde\Rpc\JsonRpc\Dispatch\CallableMapProvider;
use Horde\Rpc\JsonRpc\Dispatch\MethodDescriptor;
$provider = new CallableMapProvider(
// Method callables
[
'greet' => fn(string $name): string => "Hello, $name!",
'time' => fn(): string => date('c'),
],
// Optional: explicit method descriptors for rpc.discover
[
'greet' => new MethodDescriptor(
name: 'greet',
description: 'Greet someone by name',
parameters: [
['name' => 'name', 'type' => 'string', 'required' => true],
],
returnType: 'string',
),
],
);Methods without an explicit descriptor get a minimal auto-generated one (name only, empty description).
For more complex providers, implement the interfaces yourself. The
MathApiProvider included in this library demonstrates the pattern -- it
composes a CallableMapProvider internally:
use Horde\Rpc\JsonRpc\Dispatch\ApiProviderInterface;
use Horde\Rpc\JsonRpc\Dispatch\MethodInvokerInterface;
use Horde\Rpc\JsonRpc\Dispatch\MethodDescriptor;
use Horde\Rpc\JsonRpc\Dispatch\Result;
final class MyApiProvider implements ApiProviderInterface, MethodInvokerInterface
{
public function hasMethod(string $method): bool
{
return in_array($method, ['myapp.status', 'myapp.version'], true);
}
public function getMethodDescriptor(string $method): ?MethodDescriptor
{
return $this->hasMethod($method)
? new MethodDescriptor($method)
: null;
}
public function listMethods(): array
{
return [
new MethodDescriptor('myapp.status', 'Get application status'),
new MethodDescriptor('myapp.version', 'Get application version'),
];
}
public function invoke(string $method, array $params): Result
{
return match ($method) {
'myapp.status' => new Result(['healthy' => true]),
'myapp.version' => new Result('2.0.0'),
};
}
}The library ships with MathApiProvider as a ready-to-use example:
use Horde\Rpc\JsonRpc\Dispatch\MathApiProvider;
use Horde\Rpc\JsonRpc\JsonRpcHandler;
$math = MathApiProvider::create();
$handler = new JsonRpcHandler(
provider: $math,
invoker: $math,
responseFactory: new \Horde\Http\ResponseFactory(),
streamFactory: new \Horde\Http\StreamFactory(),
eventDispatcher: $eventDispatcher,
);It provides four methods with full descriptor metadata:
| Method | Parameters | Returns | Description |
|---|---|---|---|
math.add |
a, b (float) |
float | Add two numbers |
math.subtract |
a, b (float) |
float | Subtract b from a |
math.multiply |
a, b (float) |
float | Multiply two numbers |
math.divide |
a, b (float) |
float | Divide a by b |
math.divide throws InvalidParamsException on division by zero.
The Dispatcher provides two built-in fallback methods:
rpc.discover-- Returns a service description listing all available methods with their descriptors (name, description, parameters, return type).rpc.ping-- Returns"pong".
These are fallbacks: if your provider implements rpc.discover or rpc.ping
itself, the provider's implementation wins.
$client = new JsonRpcClient('https://api.example.com/rpc');
// Discover available methods
$description = $client->call('rpc.discover');
// => ['methods' => [['name' => 'math.add', 'description' => '...', ...], ...]]
// Health check
$pong = $client->call('rpc.ping');
// => 'pong'JsonRpcClient throws exceptions from the Horde\Rpc\JsonRpc\Exception
namespace. All implement JsonRpcThrowable:
use Horde\Rpc\JsonRpc\Exception\InternalErrorException;
use Horde\Rpc\JsonRpc\Exception\JsonRpcThrowable;
use Psr\Http\Client\ClientExceptionInterface;
try {
$result = $client->call('math.divide', [1, 0]);
} catch (JsonRpcThrowable $e) {
// JSON-RPC error (method not found, invalid params, internal error, ...)
echo $e->getMessage();
echo $e->getJsonRpcCode(); // e.g. -32602
} catch (ClientExceptionInterface $e) {
// HTTP transport error (connection refused, timeout, ...)
echo $e->getMessage();
}Throw exceptions from the Exception namespace in your provider to return
structured JSON-RPC errors:
use Horde\Rpc\JsonRpc\Exception\InvalidParamsException;
use Horde\Rpc\JsonRpc\Exception\MethodNotFoundException;
// In your invoke() method:
throw new InvalidParamsException('age must be positive');
// => {"error": {"code": -32602, "message": "age must be positive"}}Any non-JsonRpcThrowable exception thrown by a provider is caught and
sanitized to a generic "Internal error" response. Internal details are never
leaked to the client.
| Exception | Code | When to use |
|---|---|---|
ParseException |
-32700 | Malformed JSON (handled by Codec) |
InvalidRequestException |
-32600 | Structurally invalid request |
MethodNotFoundException |
-32601 | Method does not exist |
InvalidParamsException |
-32602 | Wrong or invalid parameters |
InternalErrorException |
-32603 | Server-side processing failure |
ServerErrorException |
-32000 to -32099 | Application-defined errors |
The server handler emits events via a PSR-14 EventDispatcherInterface. Wire
up listeners for logging, metrics, or auditing:
| Event | When | Key Properties |
|---|---|---|
RequestReceived |
Valid request decoded | request |
RequestDispatched |
Method executed successfully | request, result, duration |
NotificationReceived |
Notification processed | request |
ErrorOccurred |
Error response being sent | error, request |
BatchProcessing |
Batch request starting | requestCount, notificationCount |
Events live in Horde\Rpc\JsonRpc\Event\.
For existing Horde installations, HordeRegistryApiProvider bridges the old
Horde_Rpc_Jsonrpc behavior to the new adapter. It translates dot-notation
method names (calendar.list) to the slash-notation (calendar/list) that
Horde_Registry expects:
use Horde\Rpc\JsonRpc\Dispatch\HordeRegistryApiProvider;
use Horde\Rpc\JsonRpc\JsonRpcHandler;
$provider = new HordeRegistryApiProvider($registry);
$handler = new JsonRpcHandler(
provider: $provider,
invoker: $provider,
responseFactory: new \Horde\Http\ResponseFactory(),
streamFactory: new \Horde\Http\StreamFactory(),
eventDispatcher: $eventDispatcher,
);JSON-RPC Client MCP Client (LLM) SOAP Client
| | |
JsonRpcClient MCP Streamable SoapClient (PHP)
| HTTP POST |
v v v
JsonRpcHandler McpServer SoapHandler
=> Codec => McpRouter => ext-soap
=> Dispatcher | => SoapCallHandler
=> HttpHandler | |
| | |
+---------+----------+--------+---------+
| |
Shared Dispatch Layer
ApiProviderInterface
MethodInvokerInterface
MethodDescriptor
All three protocol handlers implement both RequestHandlerInterface and
MiddlewareInterface. Stack them in a middleware pipeline and each one
claims its protocol or passes through:
$app->pipe($soapHandler); // text/xml → SOAP
$app->pipe($mcpHandler); // /mcp + JSON → MCP
$app->pipe($jsonRpcHandler); // /rpc/jsonrpc + JSON → JSON-RPCThree layers with clear boundaries:
- Protocol (
Protocol\) -- Wire format: Codec, Request, Response, Error, Batch, Version, ErrorCode - Dispatch (
Dispatch\) -- Business logic: provider interfaces, dispatcher, result types (shared with MCP and SOAP) - Transport (
Transport\) -- HTTP integration: PSR-15 dual-interface handler (handler + middleware), PSR-18 client
The dispatch layer is shared across all three protocols. Write your provider once, serve it over JSON-RPC, MCP, and SOAP. See MCP-USAGE.md and SOAP-USAGE.md.