For developers
JsonRpcBundle is not a closed "all-in-one" product but a core on which you can build your own solutions. It covers the foundation of any RPC API: receiving requests, the method map, validation, data conversion, security, caching and documentation. Everything beyond that — new transports, protocols, integrations, tools — is meant to be an extension.
This section is for those who want to understand how the bundle works internally and extend it: in their own project or as a separate package for the community.
Principles the bundle is built on
Code is the single source of truth. API methods are ordinary PHP methods. Names, types, PHPDoc, attributes and validation constraints define both behavior and documentation at once, so the two never diverge.
Minimal configuration. Implementing IRpcService is enough — the bundle derives the rest itself: the method map, parameter and result schemas, type conversion. Configuration is needed only where there is a real choice.
Transport independence. A procedure does not know where the request came from: HTTP, the console, a message queue, REST or MCP via an extension. Validation, security, caching and events are the same for all.
Open for extension. Key processing stages are events, and the main mechanisms are interfaces with autoconfiguration: parameter converters, schema generators, method sources, batch strategies, token validators, route loaders. New behavior is added without changing the core.
Native Symfony mechanisms. DI container, EventDispatcher, Messenger, Lock, Validator, Cache, Translation — the bundle does not reinvent its own counterparts, so extensions are written in the usual Symfony ways.
Standards compliance. The protocol is JSON-RPC 2.0, the documentation is OpenRPC. Clients and tools that support these standards work with the API without adaptation.
Performance without sacrifices. The method map and metadata are built at container compile time, documentation is cached, and expensive work is moved to after the response has been sent.
What is in this section
- Lifecycle — how a request passes through the bundle for each entry point and at which moment each event is fired.
- Extensions — a list of available extensions.
- Lifecycle events — each event in detail, with listener examples.
- Rules for AI assistants — instruction files for Claude Code, Codex, Cursor, Copilot that plug into your existing rules.
- Known issues — what does not work as expected and how to work around it.
General guidelines for extensions
- Do not duplicate the core. An extension should not have its own method map, validation or error format. Methods come from
ServiceMap/IServiceHolder, execution goes throughRpcExecutor, errors throughrpc.error. Then#[RPC\Assertions],#[RPC\Cache],#[RPC\Lock], converters and events work for your transport automatically. - Respect API groups and versions. Determine the group from the route or configuration and write it to
ApiGroupContext; pass the version inRpcRequest. - Check the token yourself.
RpcExecutordoes not check the token. UseIRpcSecuritywith an appropriate token holder — this way the samesecurity.providersapply as for the main API. - Register routes via the core import. Then each import for a separate group gets unique route names.
- Core dependency —
ufo-tech/json-rpc-bundle: ^10.4.2or newer, if you useRpcExecutorandIApiRouteImport. - Components created at compile time (
IServiceFiller,IConstraintGenerator,IParamConvertor) are instantiated by the bundle itself while building the container. Their constructors may accept only services available at compile time or tagged collections — not scalar parameters.
Extension points
| What | How | What for |
|---|---|---|
Routing\IApiRouteImport |
autoconfiguration, tag ufo.rpc.route_import |
Load the extension's router.yaml separately for each API group: getResource(), getRouteNamePrefixAttribute() |
Routing\RpcYamlFileLoader / ApiYamlFileLoader |
inheritance, tag routing.loader |
Older way of writing a custom route loader |
Server\RpcExecutor |
autowiring | execute(IRpcRequestCarrier): RpcResponse — execute a request through the full core cycle |
Server\RequestPrepare\IRpcRequestCarrier / IRpcHttpCarrier |
implementation | Wrapper for your incoming request. IRpcHttpCarrier::getHttpRequest() binds rpc.post_response to the HTTP request and enables detached execution of notifications |
Server\ServiceMap\IServiceHolder |
tag rpc.service_holder (manual) |
Custom method source or a filter on top of ServiceMap |
Security\Interfaces\IRpcSecurity, IRpcTokenHolder |
alias / implementation | Token check from your source (header, parameter, message) |
Security\Interfaces\ITokenValidator |
alias or security.providers |
Custom token validation |
ParamConvertors\IParamConvertor |
autoconfiguration, tag rpc.param_convertor |
Conversion of custom parameter and result types |
...\Reflections\Fillers\AbstractServiceFiller |
autoconfiguration, tag ufo-rpc.service-iterator |
Reading custom method attributes into Service metadata |
...\Generate\Interfaces\IConstraintGenerator |
tag rpc.constraint |
JSON Schema for custom validation constraints |
Server\Batch\IBatchStrategy |
autoconfiguration, tag rpc.batch_strategy |
Custom batch execution strategy |
DocAdapters\Outputs\AbstractPostmanAdapter |
inheritance | Postman collection for your transport |
Server\RpcLocaleNegotiator |
autowiring | Language selection from Accept-Language for custom documentation |
translations.paths |
prependExtensionConfig() |
Translations of method names and descriptions shipped by the extension |
| Events | #[AsEventListener] |
Behavior around method execution |
Minimal adapter
// extension routes for each API group
final class AcmeRouteImport implements IApiRouteImport
{
public function getResource(): string { return __DIR__.'/../../config/router.yaml'; }
public function getRouteNamePrefixAttribute(): string { return '_acme_name_prefix'; }
}
// extension controller
final class AcmeController
{
public function __construct(
private RpcExecutor $executor,
private IRpcSecurity $security,
) {}
public function __invoke(Request $request): JsonResponse
{
$this->security->setTokenHolder(new HttpTokenHolder(/* ... */));
$this->security->isValidApiRequest();
$response = $this->executor->execute(new RpcFromAcme($request));
return new JsonResponse(/* serialized $response */);
}
}
// translations shipped by the extension
public function prepend(ContainerBuilder $container): void
{
$container->prependExtensionConfig('ufo_json_rpc', [
'translations' => ['paths' => [__DIR__.'/../../translations']],
]);
}
Working examples are in the code of mcp-bundle and rest-bundle (see Extensions).
Growing the ecosystem
We strongly encourage the growth of the ecosystem around the bundle. Every new transport, adapter or tool makes JSON-RPC on Symfony more convenient for everyone.
Write extensions. If you are missing an integration — with a message broker, a protocol, a monitoring system or a client generator — package it separately following the general guidelines above. Tell us about it, and we will add it to the list of extensions.
Report problems. Found a bug or a mismatch with the documentation? Create an issue in the bundle repository. We describe confirmed problems in the Known issues section.
Propose changes to the core. If an extension lacks an extension point, that is a signal it should be added to the core. The contribution process is described in CONTRIBUTING.md:
- Fork the repository and create a separate branch.
- Before starting, check the issue tracker; if there is no discussion yet, create an issue with your proposal.
- Follow the project's code style.
- Add tests for new features; all existing tests must pass.
- Describe in the pull request what problem is solved or what is added, and be ready for discussion.
By participating in the project, you agree to follow the code of conduct.
Report security vulnerabilities not through public issues but via the contact in the repository's SECURITY.md.