Add #[Rpc\Cache] and method results live in a PSR-6 pool for ttl seconds.
Hits skip the handler entirely.
use Knetesin\JsonRpcServerBundle\Attribute as Rpc;
#[Rpc\Method('weather.get')]
#[Rpc\Cache(ttl: 300)] // 5 minutes
final class GetWeather
{
public function __invoke(GetWeatherRequest $req): array { /* … */ }
}
What the bundle does on call:
MethodInvocationStartedEvent + MethodInvocationCompletedEvent (with
cacheHit: true), skip the handler.Notifications are never cached — they typically carry side effects you want applied each time. Errors are never cached either.
A scope is an extra contributor to the cache key — typically used to partition the cache per-user, per-IP, per-tenant, etc.
#[Rpc\Method('user.profile')]
#[Rpc\Cache(ttl: 60, scope: UserScope::class)]
final class GetMyProfile { /* … */ }
UserScope — partitions by the Symfony user identifier. Falls back to
anon for unauthenticated calls.IpScope — partitions by client IP from RequestStack.Implement Knetesin\JsonRpcServerBundle\Cache\CacheScope:
final readonly class TenantScope implements CacheScope
{
public function __construct(private TenantResolver $tenants) {}
public function key(MethodMetadata $method, RpcRequest $request): string
{
return 'tenant:' . $this->tenants->current()?->getId() ?? 'public';
}
}
Symfony autowires it. Reference by FQCN:
#[Rpc\Cache(ttl: 300, scope: TenantScope::class)]
By default the bundle uses the framework’s cache.app. Override globally:
json_rpc_server:
cache:
default_pool: 'app.short_lived'
Or use a named pool per method:
# config/packages/cache.yaml
framework:
cache:
pools:
app.long_lived:
adapter: cache.adapter.redis
default_lifetime: 86400
# config/packages/json_rpc_server.yaml
json_rpc_server:
cache:
pools:
long_lived: app.long_lived # alias → service id
#[Rpc\Cache(ttl: 86400, pool: 'long_lived')]
Tags let you wipe groups of cache entries without scanning:
#[Rpc\Cache(ttl: 600, tags: ['user:42', 'profile'])]
Every cached item is also automatically tagged with rpc.method.{name} so a
method-wide flush works without explicit tagging.
Requires a tag-aware pool. Wrap any plain PSR-6 adapter:
framework:
cache:
pools:
app.long_lived:
adapter: cache.adapter.redis
tags: true # enables tag-aware wrapping
Tag operations on non-tag-aware pools silently no-op. Plain get/set always
works.
Inject RpcCacheInvalidator to clear entries from application code:
use Knetesin\JsonRpcServerBundle\Cache\RpcCacheInvalidator;
final class UpdateUserHandler
{
public function __construct(private RpcCacheInvalidator $cache) {}
public function handle(int $userId, array $changes): void
{
// ... apply changes ...
// Clear the specific slot:
$this->cache->purge('user.profile', ['userId' => $userId]);
// Or wipe everything for this method:
$this->cache->purgeMethod('user.profile');
// Or by tag (cross-method):
$this->cache->purgeTags(['user:'.$userId]);
}
}
| Method | Effect | Needs tag-aware? |
|---|---|---|
purge(method, params) |
Drop exactly one slot. | No |
purgeMethod(method) |
Drop everything cached under this method. | Yes |
purgeTags(tags, pool?) |
Drop everything stamped with these tags. | Yes |
purgeAll(pool?) |
Wipe the entire pool. | No |
All purges are info-logged so audit trails capture who/what cleared what.
# Drop everything under user.profile
bin/console rpc:cache:clear user.profile
# Drop by tag
bin/console rpc:cache:clear --tag=user:42 --tag=tenant:acme
# Wipe a specific pool
bin/console rpc:cache:clear --all --pool=long_lived
scope: carefully; the default
no-scope means “single slot shared by everyone”.rpc.cache | {method} | {scope.key()} | sha1({stable_sorted_params})
Stable-sorted means: associative params are sorted by key, list params keep their order. Same JSON object regardless of key order → same hash.
Keys longer than 200 characters or containing reserved PSR-6 chars
({}()/\@:) collapse into rpc.{sha1} — backend-safe and bounded.