aicrion/json-rpc

A modern, attribute-driven JSON-RPC 2.0 server library with namespace-scoped handler groups and automatic method-level access control.

Maintainers

Package info

github.com/Aicrion/rpc-json-php

Homepage

pkg:composer/aicrion/json-rpc

Transparency log

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.1 2026-07-06 19:28 UTC

This package is auto-updated.

Last update: 2026-07-06 19:36:15 UTC


README

A modern, attribute-driven JSON-RPC 2.0 server library for PHP 8.2+.

Full documentation (installation, every feature, API reference) lives in docs/index.md -- built to be published on GitHub Pages. This README is a concise overview and quick-start reference.

Architecture

Unlike classic dispatcher-style RPC libraries, this library is built as a pipeline of independent stages, similar in spirit to HTTP middleware stacks. Every request flows through the same fixed sequence:

Registry\HandlerRegistry          -- resolves handler metadata via Reflection,
                                      eagerly, lazily by class, by directory
                                      scan (optionally cached to disk), or
                                      fully on-demand via a NamespaceResolver
Registry\LazyHandler              -- defers `new $class()` until the handler
                                      is actually invoked, then memoizes it

Pipeline\ResolveMethodStage        -> looks up "namespace.method"
Pipeline\AuthorizationStage        -> enforces #[Protected_] automatically
[ ...custom middleware, e.g. RateLimitStage ]
Pipeline\BindParametersStage       -> maps positional/named params to args
Pipeline\CachingStage              -> short-circuits on a #[Cacheable] hit
Pipeline\NotifyListenersStage      -> before/after/failure hooks for RpcListener
Pipeline\InvokeHandlerStage        -> resolves the LazyHandler and calls it

Kernel\RpcKernel                  -- orchestrates the pipeline, builds RpcResponse
Kernel\RpcKernelBuilder           -- fluent, immutable builder for wiring everything

Every stage implements the same Pipeline\Stage interface, so the pipeline can be extended (via withMiddleware()) without ever touching the kernel's internals.

Requirements

  • PHP >= 8.2
  • psr/container (installed automatically as a dependency; only required at runtime if you use ContainerHandlerFactory)

Installation

composer install

Running tests

composer test

Run a single test:

composer test -- --filter testNameHere

Quick start

use Aicrion\JsonRpc\Attribute\RpcHandler;
use Aicrion\JsonRpc\Attribute\RpcMethod;
use Aicrion\JsonRpc\Kernel\RpcKernelBuilder;

#[RpcHandler('math')]
final class MathHandler
{
    #[RpcMethod]
    public function add(int $a, int $b): int
    {
        return $a + $b;
    }
}

$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())
    ->build();

echo $kernel->dispatchJson(json_encode([
    'jsonrpc' => '2.0',
    'method' => 'math.add',
    'params' => ['a' => 2, 'b' => 3],
    'id' => 1,
]));
// {"jsonrpc":"2.0","id":1,"result":5}

Feature overview

Namespaced handlers

Every handler declares its own namespace via #[RpcHandler('math')], #[RpcHandler('account')], etc. Methods become reachable as math.add, account.balance, and any number of handlers/namespaces can be registered on the same kernel without colliding (a duplicate qualified name throws at build time).

Automatic protected methods

Instead of guarding each method by hand, annotate the whole handler class:

#[RpcHandler('account')]
#[Protected_]
final class AccountHandler
{
    #[RpcMethod]
    public function balance(string $accountId): array { /* protected automatically */ }

    #[RpcMethod]
    #[Unprotected]
    public function ping(): string { /* explicitly opts out */ }
}

The registry resolves protection once, at registration time, so AuthorizationStage never needs per-method wiring. The actual yes/no decision is delegated to any AuthorizationGate you provide via withAuthorizationGate() (defaults to fail-closed, denying every protected method).

Auto-discovery and lazy loading

No handler is ever instantiated until one of its methods is actually invoked -- regardless of which registration strategy you use:

$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())                                       // eager instance
    ->withHandlerClass(AccountHandler::class)                               // lazy, single class
    ->withDiscoveredHandlers(__DIR__ . '/src/Handler', 'App\\Handler')    // lazy, whole directory
    ->build();

Two ways to avoid the cost of scanning a directory on every request:

  • Cache the scan to disk: pass a cacheFile to withDiscoveredHandlers(). The directory is scanned once, the resulting class list is persisted, and every subsequent build() skips the filesystem walk entirely.
  • Resolve on demand, never scan: use withResolvedHandlers() with a NamespaceResolver (ConventionNamespaceResolver or MapNamespaceResolver). The handler class for a namespace is computed purely from a naming convention or explicit map, and verified via class_exists() the first time that namespace is actually requested.
Strategy Filesystem scan Instantiation
withHandler() / withHandlers() none immediate (eager)
withHandlerClass() none on first invocation
withDiscoveredHandlers() once per build() on first invocation
withDiscoveredHandlers(cacheFile: ...) once ever on first invocation
withResolvedHandlers() never on first invocation

Handlers needing constructor dependencies can be built through any PSR-11 container via ContainerHandlerFactory, passed to any of the lazy registration methods above.

Batch requests (JSON-RPC 2.0 spec compliant)

$responses = $kernel->dispatchBatch([
    ['jsonrpc' => '2.0', 'method' => 'math.add', 'params' => [1, 2], 'id' => 1],
    ['jsonrpc' => '2.0', 'method' => 'math.add', 'params' => [5, 5]], // notification, no response
]);
// $responses contains only the answer to id=1

dispatchJson() auto-detects whether the raw body is a single request object or a batch array and responds accordingly.

Custom middleware stages

Plug any Stage implementation into the pipeline, right after authorization and before parameter binding:

use Aicrion\JsonRpc\Pipeline\RateLimitStage;
use Aicrion\JsonRpc\Pipeline\LocalRateLimitStore;

$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())
    ->withMiddleware(new RateLimitStage(new LocalRateLimitStore(), maxCallsPerMethod: 100, windowSeconds: 60))
    ->build();

Invocation listeners

Observe every call without touching the pipeline:

final class LoggingListener implements RpcListener
{
    public function beforeInvoke(MethodDescriptor $d, array $args): void { /* ... */ }
    public function afterInvoke(MethodDescriptor $d, mixed $result): void { /* ... */ }
    public function onFailure(MethodDescriptor $d, Throwable $e): void { /* ... */ }
}

$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())
    ->withListener(new LoggingListener())
    ->build();

Listeners fire around InvokeHandlerStage only -- a cache hit never triggers them, since the real handler method was never called.

Caching with pluggable backends

Annotate any method with #[Cacheable] to memoize its result. Pick any CacheStore implementation that fits your deployment:

use Aicrion\JsonRpc\Attribute\Cacheable;

#[RpcHandler('weather')]
final class WeatherHandler
{
    #[RpcMethod]
    #[Cacheable(ttlSeconds: 300)]
    public function forecast(string $city): array { /* ... */ }
}

$kernel = (new RpcKernelBuilder())
    ->withHandler(new WeatherHandler())
    ->withCacheStore(new InMemoryCacheStore())   // or FileCacheStore, ApcuCacheStore, RedisCacheStore
    ->build();
Store Scope Use case
InMemoryCacheStore single request/process tests, CLI scripts
FileCacheStore single server, cross-request no external cache service available
ApcuCacheStore shared memory across PHP-FPM workers single-server web apps
RedisCacheStore distributed multi-server / cluster deployments
NullCacheStore none (default) caching disabled

Cache keys are derived from the method name and bound arguments via CacheKeyBuilder (default: DefaultCacheKeyBuilder), also swappable with withCacheKeyBuilder().

Rate limiting, local or distributed

RateLimitStage depends on the RateLimitStore abstraction, so the exact same middleware works identically on one server or across a cluster:

use Aicrion\JsonRpc\Pipeline\RateLimitStage;
use Aicrion\JsonRpc\Pipeline\LocalRateLimitStore;   // single server
use Aicrion\JsonRpc\Pipeline\RedisRateLimitStore;   // distributed

$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())
    ->withMiddleware(new RateLimitStage(new RedisRateLimitStore($redisClient), maxCallsPerMethod: 100, windowSeconds: 60))
    ->build();

Returning custom errors from a handler

Throwing a plain exception inside a handler is always wrapped into a generic -32000 error, protecting clients from internal stack traces. To return a specific code/message/data for an expected business failure, throw Exception\RpcErrorException instead:

use Aicrion\JsonRpc\Exception\RpcErrorException;

#[RpcMethod]
public function withdraw(float $amount): array
{
    if ($amount > 100) {
        throw new RpcErrorException('Insufficient funds', -32020, ['available' => 100]);
    }

    return ['withdrawn' => $amount];
}

This propagates untouched to the client, while any other, unexpected exception still gets wrapped as a generic internal error. Reserve custom codes below -32000 to avoid colliding with the JSON-RPC 2.0 reserved range and this library's own codes (-32001 unauthorized, -32002 rate limited).

Error codes

Code Meaning
-32700 Parse error (malformed JSON)
-32600 Invalid request (missing/invalid method, id, or empty batch)
-32601 Method not found
-32602 Invalid params
-32603 Internal error (duplicate/missing handler registration)
-32000 Handler threw an unexpected exception
-32001 Unauthorized (protected method, gate denied access)
-32002 Rate limit exceeded

Documentation and license

See docs/index.md for the full guide, including a complete API reference. Published under the MIT License.

📜 License

Created with ❤️ by Aicrion. Licensed under the MIT License. Free to use, modify, and distribute!