For developers

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

  1. 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 through RpcExecutor, errors through rpc.error. Then #[RPC\Assertions], #[RPC\Cache], #[RPC\Lock], converters and events work for your transport automatically.
  2. Respect API groups and versions. Determine the group from the route or configuration and write it to ApiGroupContext; pass the version in RpcRequest.
  3. Check the token yourself. RpcExecutor does not check the token. Use IRpcSecurity with an appropriate token holder — this way the same security.providers apply as for the main API.
  4. Register routes via the core import. Then each import for a separate group gets unique route names.
  5. Core dependency — ufo-tech/json-rpc-bundle: ^10.4.2 or newer, if you use RpcExecutor and IApiRouteImport.
  6. 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:

  1. Fork the repository and create a separate branch.
  2. Before starting, check the issue tracker; if there is no discussion yet, create an issue with your proposal.
  3. Follow the project's code style.
  4. Add tests for new features; all existing tests must pass.
  5. 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.