Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Json Rpc Bundle Laravel Package

nanofelis/json-rpc-bundle

Symfony bundle for building JSON-RPC 2.0 APIs. Expose services as RPC methods, handle requests and errors consistently, and integrate JSON-RPC endpoints with Symfony routing and dependency injection.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check before you type composer require: this is a Symfony bundle, not a Laravel package. It ships a Bundle class, a container extension, a compiler pass, and a route loaded through Symfony's routing/DI/HttpKernel stack. Dropping it into a Laravel app gives you the classes and a pile of Symfony dependencies, but nothing wires up — no service provider, no route, no container tags for the parameter converters. So there are two legitimate ways a Laravel developer "uses" this:

  1. You maintain a Symfony service (monorepo, acquired codebase, or you're the one who owns the RPC edge) and Laravel talks to it over HTTP.
  2. You're evaluating it as a design reference to reimplement the pattern in a Laravel controller — which, given the 11 stars and 0 dependents, is often the saner move.

6.0 raises the floor to PHP >= 8.4 and Symfony 6.4 / 7 / 8 (Symfony 5 dropped). Check your runtime and your host's PHP before anything else — a lot of Laravel apps are still on 8.2/8.3, and this package can no longer live in the same monorepo runtime as them. If you're upgrading an existing 5.x integration, read docs/migration.md (5.x → 6.0) and the CHANGELOG first; several changes are silent at the HTTP level and only bite your client.

Steps for the Symfony side (the primary scenario):

composer require nanofelis/json-rpc-bundle
# Flex registers Nanofelis\JsonRpcBundle\NanofelisJsonRpcBundle in config/bundles.php

In 6.0 #[JsonRpcService] is autoconfigured — the nanofelis_json_rpc tag no longer has to be applied by hand, so annotating a service is sufficient to publish it over HTTP. That's convenient and also the first thing to audit: annotate only what you intend to expose.

namespace App\Rpc;

use Nanofelis\JsonRpcBundle\Attribute\JsonRpcService;
use Nanofelis\JsonRpcBundle\Attribute\RpcMethod;

#[JsonRpcService('user')]
class UserApi
{
    #[RpcMethod]
    public function get(int $id, UserRepository $repo): array
    {
        return $repo->findOrFail($id)->toArray();
    }
}

The bundle registers a single POST endpoint (default /rpc, configurable) and maps user.get → UserApi::get. Method keys are resolved at container build time by a compiler pass and exposed through a service locator, so only the service a request names is instantiated. Smoke-test before writing any client code:

curl -s -X POST https://app.test/rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"user.get","params":{"id":1},"id":1}' | jq

Where to look first, in order:

  • The README — short, and the only real prose documentation.
  • docs/migration.md and CHANGELOG.md — you are on a package whose major releases rewrite semantics; these are load-bearing.
  • Nanofelis\JsonRpcBundle\DependencyInjection\Configuration — the authoritative list of config keys, including max_batch_size. Trust this over the README when they disagree.
  • The controller and request-handling classes — confirms the route path, the HTTP status behaviour, and how batch/notification envelopes are dispatched.
  • RpcError and its built-in subclasses — your entire error contract lives there.

First use case worth doing: collapse a handful of thin REST endpoints used only by your own frontend (dashboard reads, admin actions) into one POST /rpc surface. You get batching, consistent error envelopes, and typed params for free, and you stop bikeshedding resource URLs.

Implementation Patterns

Keep RPC classes thin. The mapped class is an entry point, not a service layer. Auth checks and param validation live there, then hand off to real domain services. 6.0 removes the worst reason to be paranoid here — only the addressed service is constructed per request, instead of every tagged service and its whole dependency graph — but the classes are still container-shared. Under FrankenPHP or RoadRunner, never store per-request state on them.

Method naming as your API contract. domain.action (user.get, report.export) with a v1. prefix when you need versioning. Versioning via method name beats versioning via route, because a single route + firewall means you cannot version the endpoint itself.

DTO params, now through the standard resolver stack. 6.0 runs RPC methods through Symfony's normal controller argument lifecycle, so #[MapRequestPayload] and custom argument resolvers work. That makes the highest-value pattern cleaner:

#[RpcMethod]
public function create(#[MapRequestPayload] CreateUserInput $input): array

A validation failure becomes a clean InvalidParams (-32602) instead of a 500. This is the pattern to copy into Laravel: Route::post + a resolver that reflects the parameter type and hands off to $request->validate() or Validator::make(...).

Errors are a product decision, not an implementation detail. Define your own subclasses of RpcError and keep a table of codes:

final class UserNotFound extends RpcError
{
    public function __construct(int $id)
    {
        parent::__construct(sprintf('User %d not found', $id), 1001);
    }
}

Translate domain exceptions in the RPC layer — don't leak Doctrine or Laravel exceptions outward. Codes in -32000..-32768 are reserved; put yours at 1000+.

Batching is now per-entry and unordered. 6.0 fixes the biggest footgun: one malformed entry no longer discards the whole batch — each entry gets its own response and valid siblings still run. Two consequences you must code for:

  • Responses may arrive in any order (spec-allowed). Correlate on id, never on array position.
  • A batch can now contain a mix of result and error members. Your client must reconcile per entry rather than treat the whole response as success or failure.

Set max_batch_size deliberately. Default is 100; 0 disables the cap. Oversized batches get -32600. If you have an existing bulk/dashboard caller sending more than 100 calls, it starts failing after the upgrade with no obvious link to the release.

Do not batch writes. There is still no transaction across batch members, and with 6.0's per-entry isolation a partial failure is more likely to be visible as a mixed array, not less. Dashboards issuing four independent reads are the ideal batch; mutations stay one-call-per-request.

Notifications (id omitted) for fire-and-forget telemetry and audit pings. The response body is empty; clients that assume JSON will break.

Auth lives in two places — and 6.0 makes the second one practical. The HTTP firewall secures the route; per-method authorization must be enforced inside each RPC method (voters/policies), because one route means route-level rules can't distinguish user.get from user.delete. For cross-cutting auth/logging, listen to kernel.controller_arguments (this replaces the removed RpcBeforeMethodEvent), which now receives a duplicate of the real request — headers, cookies, session, client IP, query string, route attributes — and no longer masquerades as a sub-request, so listeners that skip sub-requests (often where auth lives) actually run. You also get resolved, typed arguments instead of raw params.

Laravel calling the Symfony service. Wrap it once, and correlate batches by id:

final class RpcClient
{
    public function call(string $method, array $params = []): mixed
    {
        $res = Http::post(config('services.rpc.url'), [
            'jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => Str::uuid()->toString(),
        ])->throw(); // transport-level only

        $body = $res->json();
        if (isset($body['error'])) {
            throw RpcException::fromEnvelope($body['error']); // map code -> your exception classes
        }
        return $body['result'];
    }

    /** @param array<string, array{method: string, params?: array}> $calls keyed by your own id */
    public function batch(array $calls): array
    {
        $payload = [];
        foreach ($calls as $id => $call) {
            $payload[] = ['jsonrpc' => '2.0', 'method' => $call['method'], 'params' => $call['params'] ?? [], 'id' => $id];
        }

        $body = Http::post(config('services.rpc.url'), $payload)->throw()->json();
        // A rejected batch (empty, oversized, unparseable) comes back as a single object, not an array.
        $body = array_is_list($body) ? $body : [$body];

        $out = [];
        foreach ($body as $entry) {
            $out[$entry['id']] = isset($entry['error'])
                ? RpcException::fromEnvelope($entry['error'])
                : $entry['result'];
        }
        return $out; // caller inspects each entry
    }
}

Do envelope/ID handling and error-code mapping in exactly one place, and mirror the code table in both codebases or you'll spend evenings diffing constants.

Porting the pattern to Laravel (the realistic path if you don't want a Symfony service): one POST /rpc controller, a method => [class, method] map in config/rpc.php, app()->make($class) + reflection-based parameter binding, RpcError-style exceptions rendered by Handler::renderable, a max_batch_size check in middleware or a FormRequest, a try/catch per batch entry so one bad call doesn't poison the array, and $this->postJson('/rpc', [...]) in feature tests. The whole thing is ~150–200 lines and gives you the same contract without the HttpKernel dependency.

Gotchas and Tips

  • The Symfony coupling is the main cost. HttpKernel, Routing, DependencyInjection, Serializer, and likely Validator come along; 6.0 also removed the transitive symfony/twig-bundle and ext-dom requirements — require them explicitly if you were relying on them. In a pure Laravel app this is dead weight with nothing bootstrapping it. Decide up front: separate service, or reimplement.
  • HTTP 200 for errors. JSON-RPC errors are transport-successful. Any Laravel Http::throw()/status-based retry logic is blind to application errors — always inspect error.code. (6.0 does clean up several cases that used to surface as 500s: non-conforming id values like floats or arrays now answer -32600, and a missing jsonrpc key no longer emits an undefined-key warning.)
  • Content-Type: application/json is mandatory. Proxies and some clients drop it on POST and you get a bare InvalidRequest (-32600) with no hint why. If a call works in Postman but not from your app, check this first.
  • The callable surface is now an allowlist. Only public, non-magic, non-abstract methods are invocable; everything else answers -32601. This closes a real hole (services exposing __construct and letting a caller re-run it on a container-shared instance), but it means invokable services relying on __invoke/__call silently stop working. Audit your tagged services on upgrade.
  • Named vs positional params. Named params bind by PHP parameter name via reflection, so renaming a parameter is a silent breaking API change for every client. Treat param names as public API, or enforce positional params in your client wrapper.
  • Method metadata is resolved at container build time. Keys now come from a compiler pass and a service locator, so MethodNotFound (-32601) after a deploy is almost always stale container cache — bin/console cache:clear or your warmup step. Put it in the deploy script, not a runbook. Two new build-time failures to expect: duplicate #[JsonRpcService] keys (previously last-wins) and a service tagged without the attribute.
  • Batch behaviour changed in ways clients notice. Empty batch [] now returns -32600 instead of []. Results may be unordered. Oversized batches are rejected. Any 5.x client that indexed into results[0] or assumed homogeneous success is broken by 6.0 — add tests for all three before upgrading.
  • Batch is still an amplifier. No per-method timeout, no concurrency limit, no isolation between entries. max_batch_size caps the count, but pair it with proxy limits (client_max_body_size) and a per-method timeout budget — otherwise one client can pin a worker for the whole batch.
  • Error data and stack traces. In debug mode these can carry exception messages and internal class names; denormalization failures in particular leak App\Dto\.... Sanitize per environment, and assert on code, not message, in tests so you don't accidentally encode internal text into your contract.
  • Return values must be serializable. Hydrated Doctrine entities with circular relations return a 500 the moment they're behind a JSON-RPC result. Return arrays or explicit DTOs, always.
  • Extension points, in usefulness order: (1) a custom argument resolver / ValueResolver — now first-class, for e.g. resolving IDs to entities or pulling a caller identity off the request; (2) custom RpcError subclasses via the error factory; (3) a kernel.controller_arguments listener for logging, metrics, and per-method rate limiting — the cheapest place to add all three; (4) a decorator around the server if you need to wrap the whole call. Environment-gate internal services by only importing/annotating them in dev config — remember the attribute alone now publishes a service.
  • Testing: functional tests through the Symfony kernel (KernelBrowser) with a raw JSON body; assert result shape and error.code. Cover the paths that regress silently: parse error, unknown method, invalid params, magic-method call, empty batch, oversized batch, malformed id, and a batch containing exactly one bad entry alongside good ones.
  • Bus factor is the real risk. 11 stars, 0 dependents, a single maintainer — and 6.0 is a genuinely disruptive major. Pin the exact version in composer.json, keep your RPC classes free of bundle-specific types as far as possible (only the attributes and RpcError should appear), and keep a one-page internal doc of the method map, error codes, and the batch/client contract. If you ever port it to Laravel, the migration is then mechanical rather than archaeological.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource