Skip to the content.

Aicrion\JsonRpc

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

Built around a pipeline of independent stages instead of a single monolithic dispatcher, this library lets you compose exactly the behavior you need: namespaced handlers, automatic method protection, batch requests, custom middleware, invocation listeners, and pluggable caching / rate limiting – all without touching the core.

Get Started View on GitHub


Table of contents

  1. Installation
  2. Core concepts
  3. Quick start
  4. Defining handlers
  5. Namespaces
  6. Auto-discovery and lazy loading
  7. Automatic method protection
  8. Authorization gates
  9. Parameter binding
  10. Batch requests
  11. Custom middleware
  12. Invocation listeners
  13. Caching
  14. Rate limiting
  15. Error handling
  16. Returning custom errors
  17. Full HTTP example
  18. Testing
  19. API reference

Installation

Requires PHP 8.2 or higher.

composer require aicrion/json-rpc

Or, if you received the library as a standalone archive:

unzip aicrion-jsonrpc.zip
cd aicrion-jsonrpc
composer install

Run the test suite to confirm everything works on your machine:

composer test

Core concepts

Aicrion\JsonRpc processes every request through a fixed pipeline of stages. Each stage does exactly one job and passes control to the next:

ResolveMethodStage -> AuthorizationStage -> [your middleware] -> BindParametersStage -> CachingStage -> NotifyListenersStage -> InvokeHandlerStage
Concept Description
RpcHandler (attribute) Declares a class as a handler group bound to a namespace
RpcMethod (attribute) Exposes a public method as a callable RPC procedure
Protected_ / Unprotected (attributes) Control which methods require authorization
Cacheable (attribute) Marks a method’s result as cacheable
HandlerRegistry Scans handlers via Reflection and builds the method map
RpcKernel Runs the pipeline and turns requests into responses
RpcKernelBuilder Fluent, immutable way to configure and build a kernel

Quick start

<?php

require __DIR__ . '/vendor/autoload.php';

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();

$response = $kernel->dispatchJson(json_encode([
    'jsonrpc' => '2.0',
    'method' => 'math.add',
    'params' => ['a' => 2, 'b' => 3],
    'id' => 1,
]));

echo $response;
// {"jsonrpc":"2.0","id":1,"result":5}

Defining handlers

A handler is any plain PHP class annotated with #[RpcHandler]. Every public method annotated with #[RpcMethod] becomes callable through the kernel; methods without this attribute stay private to PHP and are never reachable via RPC, even if declared public.

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

#[RpcHandler('users')]
final class UserHandler
{
    #[RpcMethod]
    public function find(int $id): array
    {
        return ['id' => $id, 'name' => 'Hadi'];
    }

    #[RpcMethod(alias: 'list')]
    public function findAll(): array
    {
        return [/* ... */];
    }

    // Not exposed: no #[RpcMethod] attribute.
    public function internalHelper(): void
    {
    }
}

Use the alias argument of #[RpcMethod] when the public RPC name should differ from the PHP method name (e.g. list instead of findAll).


Namespaces

Every handler declares its own namespace through the first argument of #[RpcHandler]. This namespace becomes the prefix of every exposed method, so multiple handlers can be registered on the same kernel without colliding:

#[RpcHandler('math')]
final class MathHandler { /* math.add, math.multiply, ... */ }

#[RpcHandler('account')]
final class AccountHandler { /* account.balance, account.transfer, ... */ }

$kernel = (new RpcKernelBuilder())
    ->withHandlers([new MathHandler(), new AccountHandler()])
    ->build();

Registering the same qualified name twice (e.g. two handlers both exposing math.add) throws InvalidHandlerDefinitionException::duplicateMethod() at build time, so collisions are caught immediately instead of silently overwriting each other.


Auto-discovery and lazy loading

Manually new-ing every handler and passing it to withHandler() does not scale once you have dozens of handler classes – and it wastes memory/CPU instantiating handlers that a given request never calls. Aicrion\JsonRpc solves both problems:

In both cases, construction is deferred: only ReflectionClass is used to read attributes during scanning/registration. The real new call happens exactly once, the first time one of the handler’s methods is actually invoked, and the resulting instance is memoized for the rest of the kernel’s lifetime.

use Aicrion\JsonRpc\Kernel\RpcKernelBuilder;

$kernel = (new RpcKernelBuilder())
    // Scans src/Handler recursively, maps files to the App\Handler namespace.
    ->withDiscoveredHandlers(__DIR__ . '/src/Handler', 'App\\Handler')
    ->build();

// MathHandler, AccountHandler, etc. are all registered here, but none
// of their constructors have run yet.

$kernel->dispatch([
    'jsonrpc' => '2.0',
    'method' => 'math.add',
    'params' => [1, 2],
    'id' => 1,
]);
// Only *now* is `new App\Handler\MathHandler()` actually called.
// AccountHandler, and every other discovered-but-unused handler,
// is still never instantiated.

Injecting dependencies into discovered handlers

By default, discovered/lazy classes are built with a bare new $class() via DefaultHandlerFactory. If a handler needs constructor dependencies (a database connection, a logger, a repository…), supply a HandlerFactory – most commonly one backed by your PSR-11 container:

use Aicrion\JsonRpc\Registry\ContainerHandlerFactory;

$kernel = (new RpcKernelBuilder())
    ->withDiscoveredHandlers(__DIR__ . '/src/Handler', 'App\\Handler', new ContainerHandlerFactory($container))
    ->build();

withHandlerClass() accepts the same optional factory argument for a single class:

$kernel = (new RpcKernelBuilder())
    ->withHandlerClass(App\Handler\ReportHandler::class, new ContainerHandlerFactory($container))
    ->build();

Mixing eager and lazy registration

withHandler() (eager), withHandlerClass() (lazy, single class), and withDiscoveredHandlers() (lazy, whole directory) can all be combined freely on the same builder – the registry treats them uniformly once registered:

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

How directory scanning maps to class names

discoverPath($directory, $namespace) walks every .php file under $directory recursively and rebuilds the fully-qualified class name by appending the file’s relative path (with / replaced by \, and the .php extension stripped) to $namespace – exactly the PSR-4 convention most PHP projects (and Composer’s autoloader) already follow:

src/Handler/MathHandler.php          -> App\Handler\MathHandler
src/Handler/Billing/InvoiceHandler.php -> App\Handler\Billing\InvoiceHandler

Files that do not resolve to an existing class, or whose class lacks #[RpcHandler], are silently skipped – no exception is thrown for ordinary, non-handler PHP files living in the same directory.

Caching the discovery scan

Re-scanning a directory on every process boot (e.g. every PHP-FPM request) is wasted work once your handler set stabilizes. Pass a $cacheFile to persist the list of discovered classes on disk:

$kernel = (new RpcKernelBuilder())
    ->withDiscoveredHandlers(
        __DIR__ . '/src/Handler',
        'App\\Handler',
        cacheFile: __DIR__ . '/storage/handlers.cache.php',
    )
    ->build();

Resolve-on-demand (zero scanning, ever)

If you would rather avoid scanning a directory even once, use withResolvedHandlers() with a NamespaceResolver. No filesystem walk happens at any point – the handler class for an RPC namespace is computed purely from a naming convention (or an explicit map) and verified through PHP’s ordinary autoloader (class_exists()), exactly the first time that namespace is actually requested:

use Aicrion\JsonRpc\Registry\ConventionNamespaceResolver;

// "math"    -> App\Handler\MathHandler
// "account" -> App\Handler\AccountHandler
$kernel = (new RpcKernelBuilder())
    ->withResolvedHandlers(new ConventionNamespaceResolver(baseNamespace: 'App\\Handler'))
    ->build();

// The very first call to "math.add" triggers a single class_exists()
// check for App\Handler\MathHandler, then registers and lazily
// instantiates it. "account.balance" never touches MathHandler at all.

If your class names don’t follow a predictable convention, declare an explicit map instead:

use Aicrion\JsonRpc\Registry\MapNamespaceResolver;

$kernel = (new RpcKernelBuilder())
    ->withResolvedHandlers(new MapNamespaceResolver([
        'math' => App\Handler\MathHandler::class,
        'account' => App\Billing\AccountingHandler::class,
    ]))
    ->build();

Both resolvers implement the same NamespaceResolver interface, so you can write your own (e.g. resolving against a database table, a config file, or a service container) and pass it the same way. Multiple resolvers can be stacked with repeated withResolvedHandlers() calls; they are tried in order until one produces a valid, existing handler class.

Strategy Filesystem scan When resolution happens
withHandler() / withHandlers() none immediately (eager)
withHandlerClass() none on first method invocation
withDiscoveredHandlers() (no cache file) once per build() at build() time, lazily instantiated
withDiscoveredHandlers() (with cache file) once ever, then never again at build() time, lazily instantiated
withResolvedHandlers() never on first request for that namespace

Automatic method protection

Instead of guarding every method individually, apply #[Protected_] once, on the class. Every method inside inherits the protection automatically:

use Aicrion\JsonRpc\Attribute\Protected_;
use Aicrion\JsonRpc\Attribute\RpcHandler;
use Aicrion\JsonRpc\Attribute\RpcMethod;
use Aicrion\JsonRpc\Attribute\Unprotected;

#[RpcHandler('account')]
#[Protected_]
final class AccountHandler
{
    #[RpcMethod]
    public function balance(string $accountId): array
    {
        // Protected automatically -- no per-method attribute needed.
    }

    #[RpcMethod]
    #[Unprotected]
    public function ping(): string
    {
        // Explicitly opted out of the class-level guard.
        return 'pong';
    }
}

You can also protect a single method inside an otherwise unprotected class by placing #[Protected_] directly on that method.

Protection is resolved once, when HandlerRegistry scans the class, and stored on the corresponding MethodDescriptor. The AuthorizationStage simply reads that flag at request time – there is no manual wiring anywhere else.


Authorization gates

Protection alone only flags which methods need authorization; how that authorization is checked is delegated to an AuthorizationGate implementation you provide:

use Aicrion\JsonRpc\Contract\AuthorizationGate;
use Aicrion\JsonRpc\Registry\MethodDescriptor;

final class BearerTokenGate implements AuthorizationGate
{
    public function __construct(private readonly string $expectedToken) {}

    public function isAuthorized(MethodDescriptor $descriptor, array $params): bool
    {
        $token = $params['_token'] ?? null;

        return $token === $this->expectedToken;
    }
}

$kernel = (new RpcKernelBuilder())
    ->withHandler(new AccountHandler())
    ->withAuthorizationGate(new BearerTokenGate('secret-token'))
    ->build();

If no gate is supplied, NullAuthorizationGate is used by default, which denies every protected method. This is an intentional fail-closed default: protected methods stay inaccessible until you consciously wire up real authorization logic.

Unauthorized calls fail with error code -32001.


Parameter binding

Params may be sent either positionally (a JSON array) or by name (a JSON object) – exactly like the JSON-RPC 2.0 spec allows:

{"jsonrpc": "2.0", "method": "math.add", "params": [2, 3], "id": 1}
{"jsonrpc": "2.0", "method": "math.add", "params": {"a": 2, "b": 3}, "id": 1}

Both are bound onto the handler method’s real signature by DefaultParameterBinder, using reflection. Parameters with declared default values fall back to those defaults when omitted; nullable parameters fall back to null.

You can swap the binder entirely:

use Aicrion\JsonRpc\Contract\ParameterBinder;

final class StrictParameterBinder implements ParameterBinder { /* ... */ }

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

Batch requests

Fully compliant with the JSON-RPC 2.0 batch specification: send an array of request objects and get back an array of responses, with notifications (requests lacking an id) silently executed but omitted from the response list.

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

dispatchJson() auto-detects whether the decoded body is a single object or a batch array and replies with the matching shape:

$raw = $kernel->dispatchJson($request->getBody());
// returns a JSON object for a single request, or a JSON array for a batch

An empty batch ([]) throws InvalidRequestPayloadException with code -32600, per spec.


Custom middleware

Any class implementing the Stage interface can be spliced into the pipeline between authorization and parameter binding, without touching the kernel’s internals:

use Aicrion\JsonRpc\Message\RpcRequest;
use Aicrion\JsonRpc\Pipeline\PipelineContext;
use Aicrion\JsonRpc\Pipeline\Stage;

final class RequestLoggingStage implements Stage
{
    public function handle(RpcRequest $request, PipelineContext $context, callable $next): mixed
    {
        error_log('Calling ' . $request->method);

        return $next($request, $context);
    }
}

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

Multiple withMiddleware() calls stack in the order they were added.


Invocation listeners

For simpler cross-cutting concerns (metrics, audit trails) that do not need to intercept the pipeline, implement RpcListener instead:

use Aicrion\JsonRpc\Contract\RpcListener;
use Aicrion\JsonRpc\Registry\MethodDescriptor;
use Throwable;

final class MetricsListener implements RpcListener
{
    public function beforeInvoke(MethodDescriptor $descriptor, array $arguments): void
    {
        // start a timer, increment a counter, etc.
    }

    public function afterInvoke(MethodDescriptor $descriptor, mixed $result): void
    {
        // record success, stop the timer, etc.
    }

    public function onFailure(MethodDescriptor $descriptor, Throwable $exception): void
    {
        // record the failure
    }
}

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

Listeners are notified around InvokeHandlerStage only, after caching has already short-circuited (a cache hit does not trigger listeners, since the handler itself was never invoked).


Caching

Mark any method #[Cacheable] to memoize its result:

use Aicrion\JsonRpc\Attribute\Cacheable;
use Aicrion\JsonRpc\Attribute\RpcHandler;
use Aicrion\JsonRpc\Attribute\RpcMethod;

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

Choose any CacheStore that fits your deployment:

use Aicrion\JsonRpc\Cache\InMemoryCacheStore;
use Aicrion\JsonRpc\Cache\FileCacheStore;
use Aicrion\JsonRpc\Cache\ApcuCacheStore;
use Aicrion\JsonRpc\Cache\RedisCacheStore;

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

The cache key is computed from the method’s qualified name and its bound arguments via CacheKeyBuilder. Swap DefaultCacheKeyBuilder with your own implementation if you need extra granularity (per-tenant, per-user, per-API-version keys, etc.):

$kernel = (new RpcKernelBuilder())
    ->withHandler(new WeatherHandler())
    ->withCacheStore(new RedisCacheStore($redisClient))
    ->withCacheKeyBuilder(new TenantAwareCacheKeyBuilder($tenantId))
    ->build();

Caching is checked after parameter binding but before listener notification and invocation, so a cache hit skips both the real handler call and any registered RpcListener.


Rate limiting

RateLimitStage is a ready-made middleware built on the RateLimitStore abstraction, so the exact same code works locally or across a cluster:

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

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

// Distributed, enforced identically across every node:
$kernel = (new RpcKernelBuilder())
    ->withHandler(new MathHandler())
    ->withMiddleware(new RateLimitStage(new RedisRateLimitStore($redisClient), maxCallsPerMethod: 100, windowSeconds: 60))
    ->build();

Exceeding the limit fails with error code -32002.


Error handling

Every exception this library throws extends JsonRpcException and carries a JSON-RPC-compliant numeric code. The kernel automatically converts any caught JsonRpcException into a proper error envelope – you never need a try/catch around dispatch().

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 uncaught exception
-32001 Unauthorized (protected method, gate denied access)
-32002 Rate limit exceeded

Handler exceptions are wrapped in MethodInvocationException, which exposes the original Throwable via its public $cause property for logging purposes, while the client only ever sees a generic “execution failed” message – no internal details leak over the wire.

Returning custom errors from a handler

Throwing a plain PHP exception inside a handler method is always caught and wrapped into a generic -32000 “execution failed” error – this protects clients from ever seeing internal stack traces or implementation details. When you need to signal an expected, business-level failure (insufficient funds, resource not found, validation error) with your own code, message, and structured data, throw Exception\RpcErrorException instead:

use Aicrion\JsonRpc\Attribute\RpcHandler;
use Aicrion\JsonRpc\Attribute\RpcMethod;
use Aicrion\JsonRpc\Exception\RpcErrorException;

#[RpcHandler('wallet')]
final class WalletHandler
{
    #[RpcMethod]
    public function withdraw(float $amount): array
    {
        if ($amount > 100) {
            throw new RpcErrorException(
                message: 'Insufficient funds',
                code: -32020,
                data: ['available' => 100, 'requested' => $amount],
            );
        }

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

This propagates untouched to the client:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Insufficient funds",
    "data": {"available": 100, "requested": 500}
  }
}
Exception thrown Client sees
RpcErrorException your exact code, message, and data
any other Throwable generic -32000 “execution failed”, no internal details leaked

Reserve custom application error codes below -32000 (e.g. -32010 and beyond) to avoid colliding with the JSON-RPC 2.0 reserved range (-32768 to -32000) and with this library’s own built-in codes (-32001 unauthorized, -32002 rate limited).


Full HTTP example

A minimal public/index.php front controller for any PHP web server:

<?php

require __DIR__ . '/../vendor/autoload.php';

use Aicrion\JsonRpc\Kernel\RpcKernelBuilder;

$kernel = (new RpcKernelBuilder())
    ->withHandlers([
        new MathHandler(),
        new AccountHandler(),
    ])
    ->build();

header('Content-Type: application/json');
echo $kernel->dispatchJson(file_get_contents('php://input'));

Testing

The library ships with a full PHPUnit suite covering every feature described above.

composer install
composer test

Run a single test:

composer test -- --filter testCacheableMethodReturnsMemoizedResultOnRepeatedCalls

Generate an HTML coverage report (requires Xdebug or PCOV):

composer test:coverage

API reference

Attributes (Aicrion\JsonRpc\Attribute)

Kernel (Aicrion\JsonRpc\Kernel)

Exceptions (Aicrion\JsonRpc\Exception)

Contracts (Aicrion\JsonRpc\Contract)

Registry (Aicrion\JsonRpc\Registry)

Built-in implementations


Aicrion\JsonRpc · MIT License · PHP 8.2+