Skip to content

Redis Reliable Queue

Build a reliable queue on Redis Streams with consumer groups. You use Winter Boot for the application runtime and the Winter Redis module from Winter Modules for streams access plus auto-started consumer workers — the equivalent of Redisson PRO Reliable Queue on the JVM side: at-least-once delivery, acknowledgement on success, redelivery of unacked entries, and dead-letter routing for poison messages.

You need PHP 8.5 or later with the swoole, pcntl, and redis (phpredis) extensions. You also need a Redis 5+ server reachable from the app.

Start Redis before you run the app:

Terminal window
docker run -d -p 30637:6379 --name redis \
redis:7 redis-server --requirepass redis123

The sample uses this layout:

redis-queue/
├── bin/
│ └── application.php # Application entry point
├── config/
│ ├── application.yml # Winter Boot application config
│ └── redis-config.yml # Redis connections and queue consumers config
├── src/
│ ├── RedisQueueSampleApplication.php # Main application class
│ ├── consumer/
│ │ ├── OrderEventsConsumer.php # Queue worker (file writer)
│ │ └── RetryDemoConsumer.php # Fails first delivery transiently
│ └── controller/
│ └── RedisQueueDemoController.php # Send/inspect REST endpoints
└── composer.json # Dependencies

Require the framework and the modules package:

Terminal window
composer require suvera/winter-boot suvera/winter-modules

Switch between the source files. Each tab shows the exact file from the sample.

The single entry point. It points Winter Boot at the config directory and at the sample namespace.

<?php
namespace dev\example;
use dev\winterframework\stereotype\WinterBootApplication;
#[WinterBootApplication(
configDirectory: [__DIR__ . "/../config"],
scanNamespaces: [
['dev\\example', __DIR__ . '']
]
)]
class RedisQueueSampleApplication {
public static function main(): void {
$winterApp = new \dev\winterframework\core\app\WinterWebSwooleApplication();
$winterApp->run(self::class);
}
}

Switch between the two config files. application.yml enables the module, and redis-config.yml defines the connections plus the queue consumers.

The full sample file registers RedisModule and sets the server and app identity:

server:
port: 8080
address: 0.0.0.0
context-path: /
winter:
application:
name: Redis Queue Sample Application
id: redis-queue-sample-app
version: 1.0.0
modules:
- module: dev\winterframework\data\redis\RedisModule
enabled: true
configFile: redis-config.yml

See Configuration for every application.yml key.

Each consumer spawns workerNum Swoole worker processes at boot. Every worker loops over XREADGROUP with BLOCK on its stream and group:

  • Ack on success. Entries are XACKed only after consume() returns without throwing. Unacked entries stay in the group pending list.
  • Redelivery. A throttled XPENDING scan reclaims entries idle past claimIdleMs (for example after a transient failure or a crashed worker) via XCLAIM, bumping ConsumerRecord::getDeliveryCount().
  • Retries. Exceptions listed in transientExceptions are retried retries times in-worker first; when attempts run out the entry is left pending for redelivery. Any other exception is permanent.
  • Dead letter. Entries redelivered past maxDeliveries, or failing permanently, are XADDed to deadLetterStream (with failedStream, failedId, failedReason, failedAt fields) and acked. Without a dead-letter stream they are dropped with an error log so one poison message never blocks the stream.
  • Producing. RedisQueueServiceImpl::send($consumerOrStream, $message, $fields) appends to the consumer’s stream (or a raw stream name) and returns the entry id. Non-string payloads are JSON-encoded into the payload field.

Start the app, send messages, then check the output files and the pending list.

1. Start the application:

Terminal window
composer install
php bin/application.php

The queue workers start consuming as soon as the app boots.

2. Send a message:

Terminal window
curl -X POST "http://localhost:8080/queuedemo/send?consumer=order-events-consumer&message=hello"

3. Verify consumption:

Terminal window
cat /tmp/redis-queue-messages.txt
curl "http://localhost:8080/queuedemo/pending?stream=order-events&group=order-events-group"

You see one line per consumed message and an empty pending list ("pendingCount": 0), for example:

[2026-09-26 03:23:05] Stream: order-events | Id: 1790392985060-0 | Deliveries: 1 | Payload: hello

4. Verify redelivery and the dead letter:

Terminal window
curl -X POST "http://localhost:8080/queuedemo/send?consumer=retry-demo-consumer&message=fail-once:1"
curl -X POST "http://localhost:8080/queuedemo/send?consumer=order-events-consumer&message=poison:1"

The fail-once:1 entry fails transiently on first delivery, is reclaimed, and then appears in /tmp/redis-queue-retry.txt with Deliveries: 2. The poison:1 entry appears in the dead-letter stream:

Terminal window
curl "http://localhost:8080/queuedemo/dlq?stream=order-events-dlq"

The runnable sample ships a test-app.sh that automates all of the above: happy-path ack, redelivery, and dead-letter assertions with stream cleanup.

  • Read the Redis module for the connection templates (PhpRedisTemplate, cluster, sentinel) behind the queue consumers.
  • Compare with the SQS consumer and Kafka consumer: same auto-start worker shape, different delivery guarantees.
  • Browse all Libraries when you need S3, OpenSearch, or Doctrine in the same app.