Designing High‑Performance Event‑Driven PHP Services with RoadRunner, NATS, and Symfony Messenger

Mahmut Sarıkaya 4 min read 4 Views 0
Designing High‑Performance Event‑Driven PHP Services with RoadRunner, NATS, and Symfony Messenger

Imagine a PHP API that processes 10,000 requests per second while keeping memory usage under 200 MB. For many modern SaaS platforms that scenario is no longer a dream; it is a necessity driven by real‑time user interactions and micro‑service communication.

Why event‑driven architecture matters for PHP

Traditional request‑response cycles block the PHP interpreter for the duration of each HTTP call. When traffic spikes, the web server spawns more workers, memory consumption grows linearly, and latency climbs. An event‑driven model decouples work from the HTTP layer, allowing background consumers to handle tasks such as email dispatch, data enrichment, or WebSocket broadcasting without slowing down the front‑end.

Benchmarks from the RoadRunner project in 2023 show a 2.5× reduction in average response time when heavy I/O is offloaded to asynchronous workers. The key is to replace PHP‑FPM with a long‑living process that can keep the compiled bytecode in memory and reuse it across thousands of events.

RoadRunner as a high‑performance PHP worker

RoadRunner is a Go‑based application server that runs PHP scripts as workers, communicating via the PSR‑7/PSR‑15 interfaces. Because the PHP runtime stays loaded, opcode caches like OPcache remain hot, and the overhead of bootstrapping is eliminated.

System requirements are modest: Go 1.20+, PHP 8.1+, and at least 2 GB RAM for a small service. Installation is a single binary download followed by a composer dependency.

curl -L https://github.com/spiral/roadrunner/releases/download/v2.12.0/roadrunner-2.12.0-linux-amd64.tar.gz | tar xz && sudo mv roadrunner /usr/local/bin/

Next, add the RoadRunner PHP SDK to your project:

composer require spiral/roadrunner:^2.12 symfony/http-foundation

A minimal .rr.yaml config that launches a Symfony kernel as a worker looks like this:

rpc:
  listen: tcp://127.0.0.1:6001
http:
  address: 0.0.0.0:8080
workers:
  command: "php public/index.php"
  relay: "pipes"
  pool:
    numWorkers: 8
    allocateTimeout: 30s
    destroyTimeout: 30s

Integrating NATS for lightweight messaging

NATS is a cloud‑native messaging system designed for low latency and high throughput. Its binary protocol is smaller than AMQP, and the Go server can handle millions of messages per second with sub‑millisecond latency.

To add NATS to a PHP project, the official client library is required:

composer require nats-io/nats.php

Publishing a message from a Symfony command is straightforward:

use Nats\Connection;

$nc = new Connection();
$nc->connect();
$payload = json_encode(['userId'=>42,'action'=>'order_created']);
$nc->publish('orders.created', $payload);
$nc->close();

Subscribing inside a RoadRunner worker can be done in a loop that never exits, keeping the PHP process alive:

while (true) {
    $msg = $nc->subscribe('orders.created', function($msg) {
        $data = json_decode($msg->getBody(), true);
        // Dispatch to Symfony Messenger for further processing
        $bus->dispatch(new OrderCreatedMessage($data['userId']));
    });
    // The NATS client blocks until a message arrives, so CPU usage stays low.
}

Connecting Symfony Messenger to NATS and RoadRunner

Symfony Messenger abstracts message buses, transports, and handlers. By configuring a NATS transport, you let Messenger consume events directly from the broker while RoadRunner supplies the long‑lived process.

In config/packages/messenger.yaml, define the transport and routing:

framework:
  messenger:
    transports:
      nats_orders:
        dsn: "nats://127.0.0.1:4222/orders.created"
        options:
          queue: orders_queue
    routing:
      App\Message\OrderCreatedMessage: nats_orders
    default_bus: messenger.bus.default

Now create a handler that runs inside the RoadRunner worker:

namespace App\MessageHandler;

use App\Message\OrderCreatedMessage;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;

class OrderCreatedHandler implements MessageHandlerInterface
{
    public function __invoke(OrderCreatedMessage $msg)
    {
        // Example: write to a database, trigger a notification, or call an external API.
        // This code executes without spawning a new PHP process.
    }
}

Start RoadRunner with rr serve -c .rr.yaml. The worker will bootstrap Symfony, connect to NATS, and process messages continuously. Because the process never terminates, OPcache stays warm and the average latency per message can drop below 5 ms in a typical 4‑core VM.

Practical tips for scaling and monitoring

1. **Worker count** – Match the number of RoadRunner workers to CPU cores, but leave one core for the NATS server and OS. Use the numWorkers setting to experiment; a 16‑core machine often performs best with 12 workers.

2. **Graceful shutdown** – Implement signal handling in your worker entry script so that SIGTERM triggers a clean NATS unsubscribe and message acknowledgment. This prevents message loss during deployments.

3. **Metrics** – Export Prometheus counters from both RoadRunner (rr_metrics) and the NATS client (nats_messages_total) to visualize throughput and error rates.

4. **Back‑pressure** – NATS supports flow control; enable the max_pending option to limit how many unacknowledged messages a worker can hold. When the limit is reached, NATS will pause publishing, protecting your PHP process from memory spikes.

Conclusion

By combining RoadRunner’s long‑lived workers, NATS’s ultra‑fast messaging, and Symfony Messenger’s expressive routing, PHP teams can build services that rival Go or Node.js in raw performance while preserving the familiar Symfony ecosystem. The key steps are: keep the PHP runtime alive, offload I/O to NATS, and let Messenger orchestrate business logic. When these pieces work together, you achieve sub‑millisecond latency, predictable memory footprints, and a development experience that stays purely PHP.

Sources

RoadRunner official documentation, NATS.io official client guide, Symfony Messenger documentation

Author: Mahmut Sarıkaya — sarikayadev.com

Tags: #php event-driven #roadrunner #nats messaging #symfony messenger #async php
Share:
M

Written by

Mahmut Sarıkaya

Software Developer

Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

5 + 0 =