baldinof/roadrunner-bundle
Symfony bundle that integrates the RoadRunner high-performance PHP application server, providing an easily configurable, extendable worker with middleware support, kernel reboots, and out-of-the-box integrations for Sentry, sessions, Doctrine, Blackfire, and Xdebug.
This is a Symfony bundle, not a Laravel package, so the closest day-to-day mental model is Laravel Octane: keep the framework booted in a long-running RoadRunner worker and serve many requests without re-booting PHP per request. The first thing to internalize is that the Symfony kernel and DI container survive between requests, so state management becomes part of the job.
Minimal setup:
composer require baldinof/roadrunner-bundle
With Symfony Flex this is usually enough. Without Flex:
// config/bundles.php
Baldinof\RoadRunnerBundle\BaldinofRoadRunnerBundle::class => ['all' => true],
cp vendor/baldinof/roadrunner-bundle/.rr.* .
Then install the RoadRunner CLI and binary:
composer require --dev spiral/roadrunner-cli
vendor/bin/rr get --location bin/
Run it:
bin/rr serve
# or, for development/watch mode:
bin/rr serve -c .rr.dev.yaml
Visit http://localhost:8080.
First places to look:
config/packages/baldinof_road_runner.yaml — kernel reboot strategy, middlewares, integrations, metrics, KV..rr.yaml — production RoadRunner server/runtime config..rr.dev.yaml — watch mode and local development behavior.Baldinof\RoadRunnerBundle\Http\MiddlewareInterface — if you need request/response middleware outside Symfony’s kernel.WorkerStartEvent, WorkerStopEvent, WorkerExceptionEvent, WorkerKernelRebootedEvent.The first practical use case is replacing php-fpm with RoadRunner for a Symfony HTTP app to reduce bootstrap overhead. Start with the default integrations enabled, keep the default on_exception reboot strategy, and add only domain exceptions that you know are safe to allowed_exceptions.
A solid baseline config often looks like this:
# config/packages/baldinof_road_runner.yaml
baldinof_road_runner:
kernel_reboot:
strategy: [on_exception, max_jobs, memory]
allowed_exceptions:
- Symfony\Component\HttpKernel\Exception\HttpExceptionInterface
- Symfony\Component\Serializer\Exception\ExceptionInterface
- App\Exception\YourDomainException
max_jobs: 1000
max_jobs_dispersion: 0.2
memory_threshold_mb: 256
Use kernel reboot strategies deliberately:
on_exception is the normal default: keep the kernel warm, reboot only when an exception could leave services in a bad state.always is the safe-mode option when debugging state leaks; expect lower throughput.max_jobs is the standard mitigation for slow memory growth in long-running workers. max_jobs_dispersion prevents all workers from rebooting at exactly the same request count.memory protects against memory exhaustion independently of request count.Implement Symfony\Contracts\Service\ResetInterface on stateful services. The bundle preserves the container, but Symfony’s reset mechanism is how you clear request-scoped state, static caches, tenant context, and similar mutable data between requests.
Middlewares are useful but must be treated as worker-level infrastructure, not normal Symfony request lifecycle code:
namespace App\Middleware;
use Baldinof\RoadRunnerBundle\Http\MiddlewareInterface;
final class RequestIdMiddleware implements MiddlewareInterface
{
// Keep constructor dependencies light: this is resolved at worker start.
}
baldinof_road_runner:
middlewares:
- App\Middleware\RequestIdMiddleware
Good uses: request IDs, access logging, response headers, timing, early tenant resolution, and other cross-cutting concerns that should run before or after Kernel::handle(). Keep middleware small and stateless. Because middleware runs outside Symfony’s Kernel::handle(), do not assume the full Symfony request lifecycle, Security context, or request-scoped services are already initialized when middleware executes. Also remember that the middleware stack is resolved at worker start, so a slow middleware constructor slows every worker boot.
Use worker lifecycle events for operational wiring:
WorkerStartEvent: warm caches, validate required runtime configuration, initialize worker-level clients.WorkerStopEvent: flush metrics, close connections, release locks.WorkerExceptionEvent: log or alert on uncaught request-handling exceptions.WorkerKernelRebootedEvent: clear app-level static caches, reconnect external clients, reset tenant/request caches.Metrics are straightforward once RoadRunner RPC is configured:
# config/packages/baldinof_road_runner.yaml
baldinof_road_runner:
metrics:
enabled: true
collect:
user_login:
type: counter
help: "Number of logged in user"
# .rr.yaml
rpc:
listen: "tcp:127.0.0.1:6001"
metrics:
address: "0.0.0.0:9180"
Inject Spiral\RoadRunner\MetricsInterface into services and record counters, gauges, or histograms. In CLI commands or tests outside the RoadRunner worker, expect NullMetrics rather than a live metrics backend.
KV caching is useful for data shared between requests and workers, but it requires the extra dependencies and matching config on both sides:
# config/packages/baldinof_road_runner.yaml
baldinof_road_runner:
kv:
storages:
- example
# .rr.yaml
rpc:
listen: tcp://127.0.0.1:6001
kv:
example:
driver: memory
config: { }
The bundle creates an adapter named cache.adapter.roadrunner.kv_example, which can be wired into a Symfony cache pool:
framework:
cache:
pools:
cache.example:
adapter: cache.adapter.roadrunner.kv_example
For gRPC, use the Symfony Runtime entrypoint and the RoadRunner gRPC plugin:
server:
command: "php public/index.php"
env:
APP_RUNTIME: Baldinof\RoadRunnerBundle\Runtime\Runtime
grpc:
listen: "tcp://:9001"
proto:
- "calculator.proto"
Implement the generated service interfaces and they are registered automatically.
For local development, use .rr.dev.yaml so PHP file changes trigger reloads. VarDumper output does not appear in the HTTP response body under RoadRunner; use bin/console server:dump or the profiler instead.
For Docker, the important pieces are installing required PHP extensions such as sockets, fetching the binary during build, warming Symfony’s cache, and running RoadRunner directly:
RUN ./vendor/bin/rr get-binary --location /usr/local/bin
RUN composer dump-autoload --optimize \
&& composer check-platform-reqs \
&& php bin/console cache:warmup
EXPOSE 8080
CMD ["rr", "serve"]
The biggest pitfall is state leakage. The kernel and container are preserved between requests, so any singleton service that stores request data, user identity, locale, tenant, static caches, or open resources can bleed into the next request. Implement ResetInterface aggressively and audit static properties.
Do not allow exceptions casually. The default on_exception strategy reboots the kernel because an exception may leave services in a non-recoverable state. allowed_exceptions should include only exceptions that are guaranteed not to corrupt state. HttpExceptionInterface and serializer exceptions are usually safe; database connection failures, transaction failures, and partial-write exceptions are not.
Doctrine is handled by default integration, but it is not magic. The bundle clears opened managers and checks connection usability after requests, but you still need to watch the identity map, long-running transactions, and application-level entity caches. If you disable default_integrations, you take over that cleanup responsibility. Doctrine DBAL 3 compatibility is handled in modern releases, but connection health should still be part of your production monitoring.
Sessions work, but long-running workers change the concurrency model. Use shared session storage such as Redis or a database when running multiple workers, and watch for session locking. The bundle handles adding the session cookie to the Symfony response and closing the session even when the main handler throws, but it does not remove the need for sensible session infrastructure.
Middleware is a common source of surprises. It is resolved at worker start and runs outside Kernel::handle(), so:
Metrics require RoadRunner RPC config. If rpc.listen is missing, metrics or KV RPC calls will not work. Also, Spiral\RoadRunner\MetricsInterface only produces real metrics inside the RoadRunner worker; CLI commands and tests get a null implementation.
KV has two configuration surfaces: Symfony’s baldinof_road_runner.kv.storages and RoadRunner’s .rr.yaml kv section. Both must agree. The memory driver is useful for examples, but its durability semantics are not the same as a persistent cache backend; check the driver behavior before treating it as production cache storage.
VarDumper surprises many developers on first use: dumps do not appear in the HTTP response body. Use bin/console server:dump or the profiler, and remember that the dev reload behavior depends on running with .rr.dev.yaml.
Config/binary setup is easy to get wrong outside Flex. If the bundle is not auto-registered, register it manually and copy the .rr.* files. If using Docker, note the difference between bin/rr serve locally and rr serve when the binary is installed into /usr/local/bin.
gRPC requires RoadRunner 2’s gRPC plugin and generated PHP files from the proto definitions. Ensure APP_RUNTIME points to Baldinof\RoadRunnerBundle\Runtime\Runtime and that proto paths are correct relative to the RoadRunner process.
Xdebug and Blackfire integrations are convenience features, not production defaults. Xdebug should be trigger-mode only, and Blackfire probing should be enabled only when profiling. Leaving either active broadly will hurt worker performance.
Deprecated config still appears in older examples. should_reboot_kernel has been replaced by kernel_reboot.strategy; use the strategy-based config. The Worker class is internal, and the old Configuration class is deprecated.
For debugging, start with:
bin/rr serve -c .rr.dev.yamlbin/console server:dumpWorkerExceptionEvent listeners for uncaught request-handling failuresFinally, performance tuning is mostly about avoiding accidental work at worker boot and avoiding accidental state retention during requests. Keep middleware initialization cheap, keep services stateless where possible, reset what is stateful, use max_jobs and memory strategies as guardrails, and rely on the default Doctrine/session/Sentry integrations instead of reimplementing them unless you have a specific reason.
How can I help you explore Laravel packages today?