Download the PHP package fluffydiscord/roadrunner-symfony-bundle without Composer
On this page you can find all versions of the php package fluffydiscord/roadrunner-symfony-bundle. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download fluffydiscord/roadrunner-symfony-bundle
More information about fluffydiscord/roadrunner-symfony-bundle
Files in fluffydiscord/roadrunner-symfony-bundle
Package roadrunner-symfony-bundle
Short Description Roadrunner runtime for Symfony
License MIT
Informations about the package roadrunner-symfony-bundle
RoadRunner Runtime for Symfony
Yet another runtime for Symfony and RoadRunner.
DDEV users: see DDEV add-on.
Features
- HTTP worker — service reset runs after the response, off the request path
- Worker warmup — zero-config; first request at steady-state speed
- Streaming —
StreamedResponse,StreamedJsonResponse,BinaryFileResponse - Early Hints (103)
- Graceful error handling — real HTTP responses for
die()/exit()/fatals - Monolog
- Centrifugo —
#[AsCentrifugoChannelListener]/#[AsCentrifugoRpcListener] - typed message bus on Symfony Messenger
- Key-Value cache —
cache.adapter.rr_kv.* - Distributed locks — Symfony
LockFactoryover RR's Lock plugin - usage guide
- PostgreSQL preconnect
Installation
Usage
.rr.yaml:
.env — RR_RPC must match rpc.listen:
- Swap the kernel trait:
Service reset
| New request arrives | Your app | After the response is sent | |
|---|---|---|---|
| Stock Symfony | resets services first (on the request path) | handled after reset | nothing |
| This bundle | container already warm | handled immediately | terminate(), then services_resetter reset |
Non-shared services (
shared: false) are not reset before Symfony 8.1, even withResetInterface—services_resetterresets a throwaway instance. Fixed in 8.1.
Database connections
- PostgreSQL — connections opened at worker boot (
doctrine.preconnect). Nativepgsqldriver: every worker opens its own socket (no persistent-connection support). PDO driver: apersistentconnection is additionally reused across worker spawns. - MySQL / MariaDB — preconnect skips them; listen to
WorkerRequestReceivedEventand reset your connections.
The ORM identity map is cleared for you (
doctrineregistry implementsResetInterface). The above concerns the DBAL connection.
Configuration
fluffy_discord_road_runner.yaml
| Option | Default | Meaning |
|---|---|---|
rr_config_path |
.rr.yaml |
Path to RR config, relative to kernel.project_dir. Lets cache:warmup run without RR running (Docker builds). |
*.lazy_boot |
false |
false = boot kernel before first request (slower worker ready, consistent response times). true = boot on first request (instant ready, boot-time spikes). Many workers → true, or boot a few workers + dynamic scaling. |
http.request_factory |
auto |
native = build the Symfony Request directly, ~halves conversion cost. psr7 = PSR-7 then symfony/psr-http-message-bridge; use with a custom HttpFoundationFactoryInterface service (picked up automatically). auto = psr7 when such a service exists, else native. |
warmup.enabled |
true |
Master switch for the warmup runner, built-in warmers and the recorder. See Worker warmup. |
warmup.learn |
true |
Record which classes and cache files real responses load, replay them at every later worker boot. Covers only routes visited while learning. |
warmup.learn_requests |
30 |
Stop recording after this many responses per worker process. |
warmup.manifest_path |
null |
null = <kernel.cache_dir>/roadrunner/warmup.manifest.json. Point outside the cache dir to persist learning across deploys; self-invalidates when the container build id changes. |
doctrine.preconnect |
true |
Opens PostgreSQL connections at worker boot; other drivers ignored; runs on every boot regardless of lazy_boot. Needs doctrine/dbal. |
kv.auto_register |
true |
Registers every kv adapter from .rr.yaml as cache.adapter.rr_kv.NAME. |
kv.serializer |
null |
IgbinarySerializer when the igbinary extension is present, else DefaultSerializer. Custom: implement Spiral\RoadRunner\KeyValue\Serializer\SerializerInterface. |
kv.keypair_path |
— | Relative path to a keypair for end-to-end encryption. Needs sodium. |
Each section activates only with its package installed:
| Section | Package |
|---|---|
centrifugo |
roadrunner-php/centrifugo |
jobs |
spiral/roadrunner-jobs |
kv |
spiral/roadrunner-kv |
doctrine |
doctrine/dbal |
temporal |
temporal/sdk — options in docs/temporal.md |
Behind a load balancer / reverse proxy
Use private_ranges instead of REMOTE_ADDR as trusted proxy. The REMOTE_ADDR placeholder is resolved from $_SERVER at container build time, where no request exists yet, so trusted headers won't work. The per-request client IP is on the Request ($request->server->get('REMOTE_ADDR')), never in $_SERVER.
Response/file streaming
BinaryFileResponse, StreamedResponse, StreamedJsonResponse are fully supported. Although streamed callbacks must return a \Generator — replace echo with yield:
Early Hints (103)
sendEarlyHints() works out of the box via a headers_send() polyfill. See Symfony docs.
- Headers already emitted in a
103frame are not repeated in the final response. - The RR protocol can only add headers — no
header_remove()equivalent. A header whose value changes after the103reaches the client with both values. Send hints on the response you return:sendEarlyHints($links, $response). - With
kernel.debugon, the worker writes a STDERR line naming any affected header.
Error handling
| Failure | dev (kernel.debug) |
prod |
|---|---|---|
| exception in your code | Symfony's exception page | Symfony's error page |
| exception escaping Symfony | HtmlErrorRenderer page |
bare 500, empty body |
die() / exit() / fatal |
built-in minimal error page | bare 500, empty body |
die()/exit()/fatals are answered best-effort by a shutdown handler.- Details go to STDERR (RR worker logs) and Sentry if installed, never
stdout(goridge channel). - Dev page names where the last
dump()/dd()ran (file:line, hyperlinked viaframework.ide) and shows the dump — unless a dump server (Buggregator /debug.dump_destination) is configured, which receives it instead. Needssymfony/var-dumper; never active in prod.
Not covered:
- true out-of-memory — Symfony's fatal handler can trip RR's
stdoutCRC check first - an already-streaming response — never patched with a second frame
SIGKILL, segfault, stack overflow — PHP shutdown never runs
Best dev experience: socket relay (RR_RELAY=tcp://…/unix://…) or http.pool.debug: true.
Sentry
Configure as usual.
Monolog
Avoid the fingers_crossed handler — it leaks memory by design. It still mostly works here because ServiceResetter runs after each response, but logs may be missing after a hard error.
Centrifugo (websockets)
Listen to any event implementing FluffyDiscord\RoadRunnerBundle\Event\Centrifugo\CentrifugoEventInterface:
ConnectEvent(required)InvalidEventPublishEventRefreshEventRPCEventSubRefreshEventSubscribeEvent
Nobody answered → denied (since v8, see UPGRADE). Every request needs a listener that calls setResponse(), reject() or disconnect():
| Request | Default when unanswered |
|---|---|
| Connect | disconnect 4500 forbidden (client does not reconnect) |
| Publish, Subscribe, RPC | error 403 forbidden |
| Refresh, SubRefresh | result expired: true (client disconnected) |
Refusing a request
ConnectEvent, PublishEvent, SubscribeEvent and RPCEvent refuse without throwing. Both stop propagation; no later listener runs.
4000–4499= client reconnects,4500–4999= client stays disconnected (Centrifugo codes).- Refused request: one frame, no Sentry event, no error log, no kernel reboot.
- Refusal wins: drops a response an earlier listener set.
setResponse()after a refusal, or a second refusal →LogicException.- Refresh / SubRefresh can't be refused with an error — Centrifugo ignores it. Expire instead:
$event->setResponse(new RefreshResponse(expired: true)). - Never call
$event->getRequest()->error()yourself — the worker answers too, and the client gets two frames. - Throwing still works, but is treated as a crash: Sentry, error log, kernel reboot,
500.
#[AsCentrifugoChannelListener]
Routes PublishEvent, SubscribeEvent, SubRefreshEvent, ConnectEvent by channel name. * = wildcard.
On a class, event and method are required:
| Parameter | Type | Default | Description |
|---|---|---|---|
channel |
string |
(required) | Exact name or * pattern (chat:*) |
event |
?string |
null |
Event FQCN; inferred from the first parameter type hint on methods |
priority |
int |
0 |
Higher = called first within the matched channel |
method |
?string |
null |
Auto-detected when placed on a method |
#[AsCentrifugoRpcListener]
Routes RPCEvent by RPC method name.
| Parameter | Type | Default | Description |
|---|---|---|---|
rpcMethod |
string |
(required) | Matched against RPCEvent::getRequest()->method |
priority |
int |
0 |
Higher = called first |
method |
?string |
null |
Auto-detected when placed on a method |
Routing table is built at container compile time — one hash-map lookup per request. Handlers run in priority order and respect stopPropagation(). Routing listeners fire at priority -100, after plain #[AsEventListener] handlers at 0.
Jobs (queues)
.rr.yaml:
JobsRunEvent is dispatched once per consumed task:
Ack / nack:
- Listener returns normally → ack.
- Listener throws
\Throwable→ nack with requeue (redelivery: true) + error logged to STDERR / Sentry. A hard\Erroralso stops the worker (RR respawns it). - Worker dies mid-task (
die/exit/fatal) → shutdown handler best-effort requeues. - A listener that takes the task (
$event->getTask()->ack()/nack()/requeue()) is respected; the worker won't respond twice.
Poison messages: the default is requeue, so an always-throwing task is redelivered indefinitely. Catch inside the listener and ack-and-drop, or
nack($e, redelivery: false)yourself.
Message bus (Messenger-style)
Optional typed layer: dispatch a plain PHP object, handle it with a standard #[AsMessageHandler]. Purely additive — raw JobsRunEvent and RR Jobs services keep working, and a task this layer did not produce is left to your raw listeners.
Serialization: igbinary when the extension is present, otherwise Native (serialize()/unserialize(), handles any serializable object incl. private state). For JSON:
The strategy comes from
jobs.serializer(igbinary/native/symfony) and is recorded in the task'sx-job-serializerheader so the consumer decodes with the same one.symfonywithoutsymfony/serializerthrows a clear error.
Dispatch via the public JobDispatcher; explicit arguments override the attribute:
Everything #[AsMessageHandler] supports applies — priorities, multiple handlers, named methods, debug:messenger. Consumed jobs arrive on the Messenger transport roadrunner; scope with #[AsMessageHandler(fromTransport: 'roadrunner')].
Need the RR task (headers, manual ack/nack/requeue)? Add a ReceivedTaskInterface parameter:
Ack / nack matches the raw listener: all handlers return → ack; a handler throws → nack with requeue + STDERR / Sentry log; no registered handler → logged and acked as a no-op. The poison-message caveat applies equally.
Wire format (
x-job-class/x-job-serializerheaders, message FQCN as the RR job name) is a stable contract — changing it breaks in-flight queued tasks across an upgrade.
Worker warmup
A fresh worker's first request is several times slower than steady state; opcache.preload is a no-op in cli workers. The bundle warms during worker boot, before RR marks the worker ready. Zero config:
- Generic warmers — router, Doctrine metadata, event listeners, form types, Twig runtimes, container preload class list. Missing dependencies are skipped.
- Learned manifest — workers record what real traffic loads (
<kernel.cache_dir>/roadrunner/warmup.manifest.json); every next worker replays it at boot. Invalidated when the container is rebuilt.
Measured on a production Sylius app: first request 252 ms → 41 ms (steady state 33–43 ms).
Development (http.pool.debug: true): warmup and learning switch themselves off — one process per request keeps nothing warm. Gate is kernel.runtime_mode.worker, derived from pool.debug in .rr.yaml.
Production expectations:
display_errors=0— warnings on stdout corrupt the worker protocol.opcache.file_cache=/some/dirshares bytecode across workers (boot ~600 ms → ~200 ms). Adapted to automatically.warmup.learn_requests(default 30) — responses recorded per worker.warmup.manifest_pathoutside the cache dir keeps learning across deploys.
Warmed classes live in each worker's opcache — budget opcache.memory_consumption × worker count.
Warming your own services
Autoconfigured. Or listen to WorkerBootingEvent. A throwing warmer is logged and skipped in production.
Cold cache in dev
Suggested, not required. Sidesteps upstream Symfony bug: symfony/symfony#65447
- Dev only (
APP_DEBUG=1); prod and any warm cache are unaffected. - Concurrent cold-cache boots corrupt
<Container>Deprecations.log→ workers die at boot and respawn, requests hang instead of erroring.
Distributed locks
Optional. Symfony LockFactory backed by RR's Lock plugin over the same RPC, no extra config:
Add a lock section to .rr.yaml, then autowire LockFactory (or PersistingStoreInterface):
Temporal (beta)
[!WARNING] Beta. Flow and implementation may still change; expect breaking changes until the API settles.
Put workflows and activities on a task queue with #[TaskQueue], start them with WorkflowLauncherInterface.
→ docs/temporal.md
Developing with Symfony and RoadRunner
- Drop lazy loading; inject services immediately. Lazy services can leak memory and slow framework initialization when requests arrive.
- No local class/array caches in services — stay stateless or implement
ResetInterface. Mind theshared: falsecaveat. - Forms can leak data across requests — see OptionsResolver.
- Simplify
Usersession serialization withEquatableInterface+ custom de/serialization — avoids detached Doctrine entities and speeds up loading the user from the session.
OptionsResolver (Forms)
OptionsResolver::setDefaults() is cached — it resolves once per worker, on first use. Dynamic defaults leak across requests and sessions:
Keep defaults static; pass dynamic values at form creation:
Debugging
dd()works in dev — the rescue page names thefile:lineit ran on and shows the dump. Needssymfony/var-dumper.dump()on a successful response is still invisible: RoadRunner re-streams the output buffer to STDERR, so the HTML lands escaped in the worker log.- A dump server takes both cases over TCP — the rescue page then shows the location only and forwards the dump. Buggregator (or any
VAR_DUMPER_SERVER) also serves as a mailtrap and a local Sentry.
DDEV add-on
See the add-on repository for configuration and usage.
Credits
Inspiration taken from Baldinof's Bundle and Nyholm's Runtime.
All versions of roadrunner-symfony-bundle with dependencies
nyholm/psr7 Version ^1.8
spiral/roadrunner Version ^v2025 || ^3
symfony/dependency-injection Version ^7.4 || ^8
symfony/http-kernel Version ^7.4 || ^8
symfony/psr-http-message-bridge Version ^7.4 || ^8
symfony/runtime Version ^7.4 || ^8
symfony/framework-bundle Version ^7.4 || ^8
symfony/event-dispatcher Version ^7.4 || ^8
symfony/expression-language Version ^7.4 || ^8
spiral/roadrunner-worker Version ^v3
spiral/roadrunner-http Version ^v4
spiral/roadrunner-cli Version ^v2