✨ Framework-free JSON-RPC for PHP — strict
handler.methodrouting, strong defaults, auth drivers, gzip, rate limiting, middleware, lightweight schema validation, direct JSON usage, and docs generation.
A framework-free JSON-RPC 2.0 server library for modern PHP.
It keeps the boring parts solid — request validation, batching, auth, compression, rate limiting, hooks, docs, and predictable handler execution — while keeping your application code explicit and reviewable.
Full documentation is available at the documentation website: https://larananas.github.io/lumen-json-rpc.
Lumen JSON-RPC is built for developers who want a real server library, not a vague protocol toolkit and not a heavy framework abstraction.
- 🧱 Standalone library — plain PHP, no framework required
- 🎯 Strict
handler.methodmapping — predictable and easy to review - 🔐 Auth drivers built in — JWT, API key, or HTTP Basic
- 🧩 Direct JSON usage — use HTTP by default, or call
JsonRpcServer::handleJson()directly - 🧪 Strong protocol handling — strict request validation, batching, notifications
- 🛡️ Safe defaults — reserved methods blocked, magic methods excluded, public instance methods only
- 🗜️ Compression + rate limiting — useful production features without extra packages
- 🪝 Hooks + middleware — extension points without turning the library into a framework
- 📚 Docs generation — generate API docs from your handlers
A clean JSON-RPC 2.0 server for PHP that stays:
- explicit
- composable
- easy to wire into a plain app
- strict enough to be trusted
- a full framework
- a giant DI container
- a magical procedure registry
- a “bring 12 packages before hello world” library
Documentation website: larananas.github.io/lumen-json-rpc
composer require larananas/lumen-json-rpcRequires PHP >=8.2 and ext-json.
Composer consumers install the normal tagged package archive. Repository-only assets such as tests, examples, source docs, CI workflows, and docs-site tooling are intentionally excluded from package archives.
ext-zlib→ enables gzip request / response supportfirebase/php-jwt→ enables broader JWT algorithm support
Without optional extras, the library still works.
composer qaruns the standard local release gate: validate, audit, package verify, lint, PHPStan level 9, and PHPUnitcomposer qa:maxextends that local gate with coverage threshold checks and mutation testing; it requires a local coverage drivercomposer test:coverageandcomposer mutaterequire a local coverage driver: useXDEBUG_MODE=coverage, enablexdebug.mode=coverage, or install PCOV- CI covers the same release areas across
quality,tests,coverage, andmutationjobs; PHPUnit runs on PHP 8.2/8.3/8.4, while quality, coverage, and mutation run on PHP 8.3
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Lumen\JsonRpc\Config\Config;
use Lumen\JsonRpc\Server\JsonRpcServer;
$config = new Config([
'handlers' => [
'paths' => [__DIR__ . '/../handlers'],
'namespace' => 'App\\Handlers\\',
],
]);
$server = new JsonRpcServer($config);
$server->run();<?php
declare(strict_types=1);
namespace App\Handlers;
use Lumen\JsonRpc\Support\RequestContext;
final class User
{
public function get(RequestContext $context, int $id): array
{
return [
'id' => $id,
'name' => 'Example User',
'requested_by' => $context->authUserId,
];
}
}curl -X POST http://localhost:8000/ \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"user.get","params":{"id":1},"id":1}'{
"jsonrpc": "2.0",
"result": { "id": 1, "name": "Example User", "requested_by": null },
"id": 1
}Methods follow the handler.method pattern:
| JSON-RPC Method | Handler Class | Method |
|---|---|---|
user.get |
handlers/User.php |
get() |
user.create |
handlers/User.php |
create() |
system.health |
handlers/System.php |
health() |
That means:
user.get→ handler classUser- method
get()on that handler - discovered from your configured handlers path + namespace
- auto-discovery only scans top-level handler files in each configured path; nested directories are not discovered
No manual method registry. No hidden auto-generated procedures. No “where is this route even defined?” nonsense.
You can protect exact methods or method prefixes with:
- JWT (default driver when auth is enabled)
- API key
- HTTP Basic
'auth' => [
'enabled' => true,
'driver' => 'jwt', // jwt | api_key | basic
'protected_methods' => ['user.', 'order.'],
],Use exact method names like user.get to protect one procedure, or trailing-separator prefixes like user. to protect a whole handler surface.
HTTP is still the default, but the stable transport-agnostic entry point is JsonRpcServer::handleJson().
<?php
use Lumen\JsonRpc\Support\RequestContext;
$context = new RequestContext(
correlationId: 'demo-1',
headers: [],
clientIp: '127.0.0.1',
requestBody: '{"jsonrpc":"2.0","method":"system.health","id":1}'
);
$json = $server->handleJson(
'{"jsonrpc":"2.0","method":"system.health","id":1}',
$context,
);
echo $json;If you need to resolve auth from request headers before handing the context around, use the stable server API instead of the internal engine:
$context = $server->authenticateContext($context);If you need a custom header-based auth flow, install it on the stable server surface:
$server->setRequestAuthenticator(new MyRequestAuthenticator());You can inject app services into handlers without forcing a framework container.
<?php
use Lumen\JsonRpc\Dispatcher\HandlerFactoryInterface;
use Lumen\JsonRpc\Support\RequestContext;
$factory = new class($db) implements HandlerFactoryInterface {
public function __construct(private DatabaseService $db) {}
public function create(string $className, RequestContext $context): object
{
return new $className($this->db);
}
};
$server->setHandlerFactory($factory);Run logic before / after each request without mixing it into handlers.
<?php
use Lumen\JsonRpc\Middleware\MiddlewareInterface;
use Lumen\JsonRpc\Protocol\Request;
use Lumen\JsonRpc\Protocol\Response;
use Lumen\JsonRpc\Support\RequestContext;
$server->addMiddleware(new class implements MiddlewareInterface {
public function process(Request $request, RequestContext $context, callable $next): ?Response
{
error_log("[JSON-RPC] -> {$request->method}");
$response = $next($request, $context);
error_log('[JSON-RPC] <- done');
return $response;
}
});The default handler.method auto-discovery is the primary model. For advanced use cases, you can also register procedures explicitly:
<?php
use Lumen\JsonRpc\Dispatcher\ProcedureDescriptor;
$registry = $server->getRegistry();
$registry->register('math.add', MathHandler::class, 'add', [
'description' => 'Add two numbers',
]);
// Or batch-register descriptor objects:
$registry->registerDescriptors([
new ProcedureDescriptor('math.add', MathHandler::class, 'add', ['description' => 'Add two numbers']),
new ProcedureDescriptor('math.multiply', MathHandler::class, 'multiply'),
]);Explicit descriptors work alongside auto-discovered handlers. Descriptor metadata is used by documentation generators.
When you need richer machine-readable contracts, descriptor metadata can also provide resultSchema for generated JSON/OpenRPC output.
Auto-discovered handlers can also provide richer result contracts with a docblock tag such as @result-schema {"type":"object",...}.
When you want more than simple type binding, a handler can provide a lightweight validation schema.
<?php
use Lumen\JsonRpc\Validation\RpcSchemaProviderInterface;
final class Product implements RpcSchemaProviderInterface
{
public static function rpcValidationSchemas(): array
{
return [
'create' => [
'type' => 'object',
'required' => ['name', 'price'],
'properties' => [
'name' => ['type' => 'string', 'minLength' => 1],
'price' => ['type' => 'number'],
],
'additionalProperties' => false,
],
];
}
}Enable it with:
'validation' => [
'strict' => true,
'schema' => ['enabled' => true],
],If you do nothing, normal parameter binding still works exactly fine.
The schema support is intentionally a lightweight subset for runtime request validation. It is not a full JSON Schema implementation.
Generated JSON and OpenRPC docs also reuse these request schemas when available, and explicit descriptor metadata or handler docblock @result-schema tags can provide richer result contracts, so machine-readable docs can carry both parameter constraints and richer result contracts.
This is a scope-level comparison based on public docs and default library behavior. It is intentionally simplified and focused on developer-facing features.
| Feature | Lumen JSON-RPC | uma/json-rpc | datto/json-rpc | fguillot/json-rpc |
|---|---|---|---|---|
| Framework-free server | ✅ | ✅ | ✅ | ✅ |
| HTTP support out of the box | ✅ | ❌ | ✅ | ✅ |
| Direct JSON usage without HTTP | ✅ | ✅ | ✅ | ❌ |
Strict handler.method auto-discovery |
✅ | ❌ | ❌ | ❌ |
| Middleware pipeline | ✅ | ✅ | ❌ | ✅ |
| Optional advanced param validation | ✅ | ✅ | 🟡~ | ❌ |
| JWT built in | ✅ | ❌ | 🟡~ | ❌ |
| API key built in | ✅ | ❌ | 🟡~ | ❌ |
| Basic auth built in | ✅ | ❌ | 🟡~ | ✅ |
| Rate limiting built in | ✅ | ❌ | ❌ | ❌ |
| Gzip support built in | ✅ | ❌ | ❌ | ❌ |
| Docs generation built in | ✅ | ❌ | ❌ | ❌ |
| No mandatory external Composer deps in production | ✅ | ❌ | ✅ | ✅ |
Lumen JSON-RPC is opinionated in a very specific way:
- stricter than the “just map whatever” style
- more complete than minimal protocol-only cores
- lighter than solutions that push you into container/schema stacks immediately
If that trade-off matches how you like to build plain PHP backends, that is exactly where it shines.
These choices are intentional. They are not accidental omissions.
Because it stays easy to reason about.
When you see user.get, you know where to look:
- handler
User - method
get() - in your configured handlers path
That keeps the execution path explicit, reviewable, and boring in a good way.
Because a second mapping layer becomes busywork fast.
A lot of JSON-RPC libraries let you manually register callbacks or procedure maps. That can be useful in tiny demos, but in real apps it also means:
- more wiring to maintain
- more chances to forget an entry
- more distance between the request method and the actual PHP code
Lumen JSON-RPC chooses discovery + convention instead. If the handler exists and the method is callable, the library can resolve it directly.
Because it is the most common modern default for API-style auth.
But “default” does not mean “forced”.
You can switch to:
api_keybasic
without changing the rest of the server model.
So the default is opinionated, but the library is still practical.
The resolver is intentionally strict:
- methods starting with
rpc.are always rejected - method names must match
handler.method - magic methods (
__construct,__call, etc.) are blocked - only public instance methods declared on the concrete handler class are callable
- static methods are excluded
- inherited methods are excluded
- internal framework/library methods are excluded
This keeps execution paths explicit and limits surprises.
jwt(default when auth is enabled)api_keybasic
'auth' => [
'enabled' => true,
'driver' => 'jwt',
'protected_methods' => ['user.', 'order.'],
'jwt' => [
'secret' => 'your-secret-key',
'algorithm' => 'HS256',
'header' => 'Authorization',
'prefix' => 'Bearer ',
'issuer' => '',
'audience' => '',
'leeway' => 0,
],
],'auth' => [
'enabled' => true,
'driver' => 'api_key',
'protected_methods' => ['user.'],
'api_key' => [
'header' => 'X-API-Key',
'keys' => [
'demo-key-123' => [
'user_id' => 'service-name',
'roles' => ['service'],
'claims' => ['source' => 'api_key'],
],
],
],
],'auth' => [
'enabled' => true,
'driver' => 'basic',
'protected_methods' => ['user.'],
'basic' => [
'users' => [
'admin' => [
'password_hash' => password_hash('secret', PASSWORD_DEFAULT),
'user_id' => 'admin',
'roles' => ['admin'],
],
],
],
],Prefer password_hash for production credentials. Plaintext password remains supported for development and tests.
public function me(RequestContext $context): array
{
return [
'id' => $context->authUserId,
'roles' => $context->authRoles,
'email' => $context->getClaim('email'),
];
}See:
Apache note: depending on your setup, you may need to forward the
Authorizationheader explicitly. See the authentication guide and the auth example for a working.htaccesssnippet.
File-based rate limiting with atomic file locking:
'rate_limit' => [
'enabled' => true,
'max_requests' => 100,
'window_seconds' => 60,
'strategy' => 'ip',
'fail_open' => false,
],By default, rate limiting is fail-closed: if the storage backend cannot be opened or locked, the request is denied with HTTP 429 instead of silently bypassing the limit. Set fail_open: true only when you explicitly prefer availability over strict enforcement.
Rate-limited requests return:
- HTTP
429 - JSON-RPC error
-32000 - headers such as:
X-RateLimit-LimitX-RateLimit-RemainingRetry-After
Batch weight counts actual items received, and consumption is atomic.
The rate limit storage is pluggable. Implement RateLimiterInterface to use Redis, Memcached, or any backend:
$server->setRateLimiter(new MyRedisRateLimiter());An InMemoryRateLimiter is included for testing.
Batch requests are limited to batch.max_items (default: 100):
'batch' => [
'max_items' => 50,
],- Empty batch (
[]) returns-32600 Invalid Request - Oversized batch returns
-32600 Invalid Requestwith the limit in the error data - Batch of only notifications returns HTTP 204
- Mixed batches return responses only for non-notification requests
- Mixed valid/invalid batches are not guaranteed to preserve original input order in the response array
If ext-zlib is available:
compression.request_gzip: true(default) acceptsContent-Encoding: gzipcompression.response_gzip: truesends gzipped responses when the client advertises support
If ext-zlib is not available, the library degrades cleanly.
It does not become uninstallable just because gzip is unavailable.
'response_fingerprint' => ['enabled' => true, 'algorithm' => 'sha256'],Successful single responses can include an ETag header.
Clients can then use If-None-Match for conditional requests:
- matching fingerprint → HTTP
304 - applies to non-batch single requests only
Hooks and middleware are complementary:
- hooks are great for lightweight lifecycle events
- middleware is better when you want to wrap request execution
BEFORE_REQUEST -> BEFORE_HANDLER -> [handler] -> AFTER_HANDLER -> ON_RESPONSE -> AFTER_REQUEST
ON_ERROR fires instead of AFTER_HANDLER on exception.
ON_AUTH_SUCCESS / ON_AUTH_FAILURE fire during authentication.
$server->getHooks()->register(
HookPoint::BEFORE_HANDLER,
function (array $context) {
return ['custom_data' => 'value'];
}
);Hook callbacks run inline. By default, thrown hook exceptions are logged and suppressed so request execution can continue. Set hooks.isolate_exceptions to false if you want hook failures to abort the request instead.
POST /handles JSON-RPC requests- empty POST body returns
-32600 Invalid Request GET /returns a health/status JSON whenhealth.enabledistrue- unsupported methods return HTTP
405, withAllow: POST, GETwhen health checks are enabled andAllow: POSTwhen they are not - set
content_type.strict: trueto requireapplication/jsonon POST
HTTP status codes are reserved for transport-level outcomes:
- JSON-RPC parse/protocol/application errors return HTTP
200with a JSON-RPC error body - all-notification batches return HTTP
204with no body - successful conditional requests may return HTTP
304 - fingerprint
ETags are representation-specific under gzip negotiation, and conditional304responses preserve cache-relevant headers such asETagandVary: Accept-Encoding - transport rate limiting returns HTTP
429plus a JSON-RPC error body - unsupported HTTP methods return HTTP
405
Parameters are type-checked and mapped to -32602 Invalid params for mismatches.
- wrong scalar types produce
-32602 - missing required parameters produce
-32602 - unknown named parameters produce
-32602 - surplus positional parameters produce
-32602 - optional parameters use their defaults when omitted
- both positional and named parameters are supported
inttofloatcoercion is allowedRequestContextis injected automatically when declared as the first method parameter
This keeps handler signatures clean without turning binding into magic.
Generate docs from handler metadata:
php bin/generate-docs.php --config=./config.php --format=markdown --output=docs/api.md
php bin/generate-docs.php --config=./config.php --format=html --output=docs/api.html
php bin/generate-docs.php --config=./config.php --format=json --output=docs/api.json
php bin/generate-docs.php --config=./config.php --format=openrpc --output=docs/openrpc.jsonAdjust --config to your application's config file path. The docs generator is included in Composer package archives; repository docs, examples, and the docs-site builder remain repository-only.
Supported formats:
markdown— Markdown documentation (default)html— Styled HTML pagejson— Machine-readable JSONopenrpc— OpenRPC 1.3.2 specification for client generation and tooling, validated in tests against the bundled schema fixture
The stable public surface is centered on JsonRpcServer and its documented collaborators such as RequestContext, HandlerFactoryInterface, MiddlewareInterface, RateLimiterInterface, hooks, procedure descriptors, and stable server accessors like getHooks(), getRegistry(), and getLogger().
JsonRpcServer::getEngine() is available as an escape hatch for advanced integrations, but JsonRpcEngine remains internal and is not covered by backward-compatibility guarantees between minor releases.
Release policy:
- patch releases may fix bugs, docs, tests, packaging, and internal implementation details without changing the documented stable public API
- minor releases may add new public capabilities, but internal APIs such as
JsonRpcEnginemay change without backward-compatibility guarantees - major releases may change the documented stable public API
A minimal server with handlers and no auth. Repository examples are published with the source repository, not the package archive:
Shows JWT auth with a working example app:
Shows:
-
custom handler factory
-
middleware
-
schema validation
A tiny HTML page that lets you send raw JSON-RPC requests and inspect the raw response:
| Code | Meaning | When |
|---|---|---|
| -32700 | Parse error | Invalid JSON |
| -32600 | Invalid Request | Malformed request, empty body, empty batch |
| -32601 | Method not found | Unknown or reserved method |
| -32602 | Invalid params | Missing, wrong type, unknown, or surplus parameters |
| -32603 | Internal error | Handler or middleware exception, serialization failure |
| -32000 | Rate limit exceeded | Too many requests |
| -32001 | Authentication required | Protected method without valid credentials |
| -32099 | Custom server error | Application-defined |
JsonRpcExceptionsubclasses are preserved with their original codes- only unknown
Throwablemaps to-32603 - debug mode includes stack traces
- production mode strips them
- JSON serialization failures in batch responses are isolated per response
See the configuration guide for the full configuration reference with all keys, defaults, and descriptions.
For architecture details and the request lifecycle, see the architecture guide.
For authentication-specific setup and integration notes, see the authentication guide.
vendor/bin/phpunitSecurity-sensitive behavior includes:
- method execution restricted to public instance methods on the concrete handler class
- reserved
rpc.*namespace blocked - JWT algorithm confusion prevented (
algmust match config exactly) - server refuses to start with invalid auth driver configuration
- server refuses to start with auth enabled but invalid required auth config
- gzip bombs mitigated with size limits enforced before and after decompression
- log injection prevented (newlines escaped, context JSON-encoded)
- rate limiting uses atomic file locking with configurable fail-open / fail-closed behavior
See the security guide for details.
This repository includes a static documentation site published via GitHub Pages.
Default URL: larananas.github.io/lumen-json-rpc
This is a repository-only maintenance step. The generated docs-site/ output is gitignored, and the source docs plus site builder are export-ignored from package archives.
php bin/build-docs-site.phpThis generates the docs-site/ directory with 15 HTML pages.
Docs are deployed automatically by the Deploy Docs GitHub Actions workflow:
- On release: publish a GitHub release and docs deploy automatically
- Manual trigger: go to Actions → Deploy Docs → Run workflow
- Go to Settings → Pages
- Set Source to GitHub Actions (not "Deploy from a branch")
- Trigger a deployment (release or manual workflow dispatch)
- The site will be available at
https://larananas.github.io/lumen-json-rpc/
Custom domains are not automatic — they require manual DNS and GitHub configuration.
To use a custom domain:
- Configure your DNS provider to point a CNAME record to
larananas.github.io - In GitHub Settings → Pages → Custom domain, enter your domain
- Build the docs with the
DOCS_CNAMEenvironment variable set:
DOCS_CNAME=your-domain.example.com php bin/build-docs-site.phpFor CI deployments, add DOCS_CNAME as a repository secret and reference it in the workflow.
Without DOCS_CNAME, no CNAME file is emitted and the standard GitHub Pages project URL is used.
Lumen JSON-RPC is free software licensed under the GNU Lesser General Public License, version 3 or any later version (LGPL-3.0-or-later).
In practical terms: you can use this library in both open-source and proprietary applications. You can integrate it into your own codebase, extend it, subclass it, and build commercial or closed-source software on top of it without having to release your whole application under the LGPL.
The main condition is about the library itself:
- if you distribute a modified version of Lumen JSON-RPC,
- those modifications to the library must remain available under the LGPL.
This is an intentional choice: the goal is to keep the library easy to adopt in real-world PHP projects while ensuring that improvements to the core engine are contributed back when they are distributed.
For the exact legal terms, see the LICENSE file.
For licensing or project-related questions: larananas.dev@proton.me