larananas / lumen-json-rpc
Illuminate your PHP backend with JSON-RPC 2.0 clarity.
Requires
- php: >=8.2
- ext-json: *
Requires (Dev)
- infection/infection: ^0.32
- justinrainbow/json-schema: ^5.3 || ^6.0
- phpstan/phpstan: 2.0
- phpunit/phpunit: ^10.5
Suggests
- ext-zlib: Optional. Required for gzip request/response compression support.
- firebase/php-jwt: Optional. The library includes a built-in HMAC JWT decoder. Install this for broader algorithm support or advanced token handling.
This package is auto-updated.
Last update: 2026-04-19 16:27:00 UTC
README
β¨ Framework-free JSON-RPC for PHP β strict
handler.methodrouting, strong defaults, auth drivers, gzip, rate limiting, middleware, schema validation, direct core usage, and docs generation.
A production-grade, framework-free JSON-RPC 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.
π Documentation
Full documentation is available at the documentation website: https://larananas.github.io/lumen-json-rpc.
π Why this feels good in real projects
Lumen JSON-RPC is built for developers who want a real server library, not a vague protocol toolkit and not a heavy framework abstraction.
You get, out of the box
- π§± 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 core usage β use HTTP by default, or call the engine 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
What it is trying to be
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
What it is not trying to be
- a full framework
- a giant DI container
- a magical procedure registry
- a βbring 12 packages before hello worldβ library
π¦ Install
Documentation website: larananas.github.io/lumen-json-rpc
composer require larananas/lumen-json-rpc
Optional extras
ext-zlibβ enables gzip request / response supportfirebase/php-jwtβ enables broader JWT algorithm support
Without optional extras, the library still works.
β‘ Quick Start
1) Create an entry point (public/index.php)
<?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();
2) Create a handler (handlers/User.php)
<?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, ]; } }
3) Send a request
curl -X POST http://localhost:8000/ \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"user.get","params":{"id":1},"id":1}'
4) Response
{
"jsonrpc": "2.0",
"result": { "id": 1, "name": "Example User", "requested_by": null },
"id": 1
}
π§ The 30-second mental model
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
No manual method registry. No hidden auto-generated procedures. No βwhere is this route even defined?β nonsense.
β¨ What you gain beyond basic JSON-RPC
π Multiple auth drivers
You can protect 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.'], ],
π§© Direct core usage
HTTP is still the default, but you can also call the engine directly when you need transport-independent usage.
<?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;
ποΈ Handler factory for lightweight DI
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);
πͺ Middleware pipeline
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; } });
π Explicit procedure descriptors (optional)
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.
β Optional schema validation
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.
π At a glance: how it compares
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 core 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 | β | β | β | β |
Why this matters
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.
π§ Design decisions
These choices are intentional. They are not accidental omissions.
1) Why handler.method?
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.
2) Why no manual method registry?
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.
3) Why JWT by default when auth is enabled?
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.
π‘οΈ Handler Safety
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.
π Authentication
Available drivers
jwt(default when auth is enabled)api_keybasic
Example: JWT
'auth' => [ 'enabled' => true, 'driver' => 'jwt', 'protected_methods' => ['user.', 'order.'], 'jwt' => [ 'secret' => 'your-secret-key', 'algorithm' => 'HS256', 'header' => 'Authorization', 'prefix' => 'Bearer ', 'issuer' => '', 'audience' => '', 'leeway' => 0, ], ],
Example: API key
'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'], ], ], ], ],
Example: Basic auth
'auth' => [ 'enabled' => true, 'driver' => 'basic', 'protected_methods' => ['user.'], 'basic' => [ 'users' => [ 'admin' => [ 'password' => 'secret', 'user_id' => 'admin', 'roles' => ['admin'], ], ], ], ],
Access auth data in handlers
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 docs and the auth example for a working.htaccesssnippet.
π¦ Rate Limiting
File-based rate limiting with atomic file locking:
'rate_limit' => [ 'enabled' => true, 'max_requests' => 100, 'window_seconds' => 60, 'strategy' => 'ip', 'fail_open' => true, ],
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.
Custom backends
The rate limit storage is pluggable. Implement RateLimiterInterface to use Redis, Memcached, or any backend:
$server->getEngine()->getRateLimitManager()->setLimiter(new MyRedisRateLimiter());
An InMemoryRateLimiter is included for testing.
π¦ Batch Limits
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
ποΈ Compression
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 Fingerprinting
'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
Hooks and middleware are complementary:
- hooks are great for lightweight lifecycle events
- middleware is better when you want to wrap request execution
Hook flow
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.
Hook example
$server->getHooks()->register( HookPoint::BEFORE_HANDLER, function (array $context) { return ['custom_data' => 'value']; } );
π Transport behavior
POST /handles JSON-RPC requests- empty POST body returns
-32600 Invalid Request GET /returns a health/status JSON whenhealth.enabledistrue- non-POST, non-GET methods return HTTP
405 - set
content_type.strict: trueto requireapplication/jsonon POST
π§ Parameter binding
Parameters are type-checked and mapped to -32602 Invalid params for mismatches.
Supported behavior
- 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.
π Documentation generation
Generate docs from handler metadata:
php bin/generate-docs.php --format=markdown --output=docs/api.md php bin/generate-docs.php --format=html --output=docs/api.html php bin/generate-docs.php --format=json --output=docs/api.json php bin/generate-docs.php --format=openrpc --output=docs/openrpc.json
Supported formats:
markdownβ Markdown documentation (default)htmlβ Styled HTML pagejsonβ Machine-readable JSONopenrpcβ OpenRPC 1.3.2 specification for client generation and tooling
π§ͺ Examples
Basic example
A minimal server with handlers and no auth:
Auth example
Shows JWT auth with a working example app:
Advanced example
Shows:
-
custom handler factory
-
middleware
-
schema validation
Browser demo
A tiny HTML page that lets you send raw JSON-RPC requests and inspect the raw response:
π¨ Error codes
| 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 |
Error handling notes
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
βοΈ Configuration
See docs/configuration.md for the full configuration reference with all keys, defaults, and descriptions.
For architecture details and the request lifecycle, see docs/architecture.md.
For authentication-specific setup and integration notes, see docs/authentication.md.
π§ͺ Running tests
vendor/bin/phpunit
π Security
Security-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 docs/security.md for details.
π Documentation Website
This repository includes a static documentation site published via GitHub Pages.
Default URL: larananas.github.io/lumen-json-rpc
Build the site locally
composer docs:build
This generates the docs-site/ directory with 15 HTML pages.
Deployment
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
Enabling GitHub Pages (one-time setup)
- 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 domain (optional)
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 composer docs:build
For 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.
π License
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.
βοΈ Contact
For licensing or project-related questions: larananas.dev@proton.me