timiki/rpc-server-bundle
Symfony bundle for building JSON-RPC servers. It exposes application services as remote RPC methods, handles JSON-RPC requests and responses, and integrates configuration, routing, and service tagging into Symfony. A lightweight way to add an RPC API to existing Symfony applications.
composer require timiki/rpc-server-bundle
config/bundles.php (Symfony Flex often does this automatically):
return [
// ...
Timiki\Bundle\RpcServerBundle\RpcServerBundle::class => ['all' => true],
];
/rpc):
# config/routes/rpc.yaml
rpc_server:
resource: "@RpcServerBundle/Resources/config/routing.xml"
Or wire your own route to RpcServerBundle\Controller\RpcController::__invoke.namespace App\Rpc;
use App\Repository\UserRepository;
use Timiki\Bundle\RpcServerBundle\Attribute\RpcMethod;
final class UserRpc
{
public function __construct(private UserRepository $users) {}
#[RpcMethod(name: 'user.get')]
public function getUser(int $id): array
{
$user = $this->users->find($id);
if (!$user) {
throw new \RuntimeException('User not found', -32001);
}
return ['id' => $user->getId(), 'name' => $user->getName()];
}
}
Autoconfiguration registers the tag; if you disable autoconfigure, add tags: ['rpc_server.method'].curl -X POST http://localhost:8000/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"user.get","params":{"id":1},"id":1}'
Timiki\Bundle\RpcServerBundle\Attribute\RpcMethod, the bundle's DependencyInjection\Configuration (for method mappings and serializer options), and the shipped controller/routing XML. Those three files tell you 90% of how the package works.The first real-world use case is exposing an internal service as a JSON-RPC endpoint to a JS frontend (admin panels, Vue/React apps, or another backend) without building a REST facade.
Attribute-driven registration (recommended). Rely on #[RpcMethod] on public methods plus autowiring. The bundle tags services automatically; the method name becomes the JSON-RPC method string. Keep one RPC class per domain (e.g., UserRpc, OrderRpc) so method names stay namespaced by convention (user.get, order.create).
Config-file registration for legacy services. When you can't (or shouldn't) add attributes to a class — e.g., third-party code, or you need a stable external method name — map methods in config/packages/rpc_server.yaml:
rpc_server:
methods:
legacy.ping:
service: 'App\Legacy\LegacyService'
method: 'ping'
Named vs positional params. JSON-RPC allows both. Prefer named params ({"id": 1}) in your clients so parameter order in PHP doesn't dictate the contract. The bundle resolves positional params against method signatures, so reordering arguments breaks clients — treat the signature as a public API.
Batch requests. Clients can push an array of requests in one HTTP call. Use this from dashboards that hydrate several panels. On the server, keep method implementations small and idempotent — batches are not transactional and one failure doesn't roll back others.
Errors as exceptions. Throw an exception with a JSON-RPC error code as the message code (as in the example above). For richer control, implement the package's RPC exception interface to set code, message, and data explicitly. Never leak exceptions with stack traces to clients — catch domain exceptions and rethrow as RPC-safe types, or register a listener that maps them.
Validation is your responsibility. The bundle doesn't run Symfony Validator on params for you (you can opt in by decorating the controller or adding a listener). Wrap incoming DTOs in a value object and call $validator->validate() at the top of each method, or install a listener that inspects the method signature for DTOs and validates params automatically — the DTO pattern composes well here:
#[RpcMethod(name: 'user.update')]
public function update(UpdateUserInput $input): array { /* validate inside */ }
Symfony integration points. Everything is a normal service: inject repositories, mailers, and buses. Dispatch domain events or queue messages from inside RPC methods the same way you would from a controller — the JSON-RPC layer is just transport. Use Messenger for slow operations and return a job id; JSON-RPC calls should stay fast.
HTTP middleware. Authentication and rate-limiting live outside the bundle. Use standard Symfony security.yaml request matchers on the /rpc path, a kernel.request listener, or a KernelEvents::CONTROLLER subscriber to inspect $request->headers and stash the authenticated user in the service container. Because all RPC methods share one URL, per-method ACL checks belong inside the method (or a decorator) — not in security.yaml.
Versioning. Run multiple endpoints (/rpc/v1, /rpc/v2) pointing at the same controller but with different method-name prefixes, or tag RPC services per version and gate them with a listener. This is easier than trying to version individual methods.
Method name collisions are silent until runtime. Two services registered with the same #[RpcMethod(name: ...)] will overwrite each other in the method map, and you'll only notice when one returns 404/Method not found. Prefix systematically (entity.action) and keep a list in docs.
Only public methods can be exposed. Attributes on protected/private methods are ignored (or throw depending on version). If a method "isn't there," confirm public and that the service is tagged (bin/console debug:container --tags | grep rpc).
Route order matters. If the bundle's route is imported after a catch-all route, requests never reach the controller. Put the JSON-RPC import near the top of routes.yaml and give the path a distinct prefix (/api/rpc) to avoid clashes with your website.
Notification requests (no id). JSON-RPC notifications expect a 204 No Content with an empty body. If clients see a JSON parse error on 204, they're wrongly treating the response as JSON — check your client, not the server. Confirm you're returning null vs. empty string where it matters.
Batch limits. Unbounded batches are an easy DoS vector. Cap batch size at the web-server level (e.g., client_max_body_size, body-size limits in Nginx/FrankenPHP) or add a kernel.request guard that rejects arrays longer than N.
Serializer context. Symfony's Serializer is used to normalize results; DateTime, Uuid, Money, and entities with circular references will bite you. Register normalizers explicitly and prefer returning plain arrays/DTOs from RPC methods. A circular reference in an entity surfaces as a 500 with a vague serialization error — return value objects instead of Doctrine entities.
Error codes must be integers in the reserved/exposed ranges. Use -32700..-32600 for transport-level errors, -32000..-32099 for server/application errors, and reserve -32601 (method not found) and -32602 (invalid params) for the framework. Throwing new \RuntimeException('x', 500) produces a nonstandard code clients may reject — map HTTP-ish statuses to JSON-RPC codes deliberately.
Debugging. Turn on Symfony's profiler and check the request panel first — it shows the parsed payload and, if the bundle provides one, a data collector with the resolved method. Add a kernel.exception listener that logs $e->getMessage() and the request body for server-side errors, but strip data/trace from the response in production. A raw {"jsonrpc":"2.0","error":{"code":0,"message":"Internal error"}} usually means a PHP fatal in a method — check var/log/prod.log.
Caching method metadata. If you have many RPC services, the method map is built on each request in dev but should be cached in prod. Keep rpc_server config deterministic (no dynamic closures) so the cache warmer can serialize it; otherwise you'll see cache-miss warnings on boot.
Extension points. Prefer decorators over forks: wrap RpcServer in your own service to inject auth/logging/metrics, register a kernel.controller listener to short-circuit before dispatch, or ship a tiny RpcExceptionMapper service you call from a try/catch in each method. The bundle is small (20 stars, one maintainer) — don't bank on deep extension APIs being stable; wrap and delegate.
Testing. Because RPC methods are plain services, unit-test them directly — no need to boot the kernel. For the transport layer, use KernelBrowser and POST JSON to /rpc; assert the jsonrpc, id, and result/error shape explicitly. Add one "contract" test per exposed method that at minimum checks a happy path and a not-found path so signature regressions surface in CI.
How can I help you explore Laravel packages today?