The development process (issue, worktree, review, PR, merge) is in docs/workflow.md, the release process in docs/release-workflow.md. Everything is written in English (code, comments, docs, commits, issues).
crazy-goat/rabbit-stream is a PHP library implementing the RabbitMQ Streams Protocol client (port 5552). Its Composer runtime dependencies are psr/log ^3.0 and the ext-mbstring PHP extension; ext-sockets is a development dependency used by tests.
- Root namespace:
CrazyGoat\RabbitStream - PSR-4 autoloading:
src/→CrazyGoat\RabbitStream\ - PHP 8.1+ required
# Install dependencies
composer install
# Run all tests
./vendor/bin/phpunit
# OR
composer test
# Run only unit tests
./vendor/bin/phpunit --testsuite unit
# OR
composer test:unit
# Run E2E tests (requires RabbitMQ — use the script below)
./run-e2e.sh
# Run every lint step (PHPCS, Rector dry-run, PHPStan, kb-lint, docs link check,
# test suite coverage, shellcheck). Check-only; runs all steps, exits non-zero on any failure.
bin/lint.sh
# OR
composer lint
# Apply the fixers (Rector, PHPCBF, kb-lint --fix), then check again
bin/lint.sh --fix
# OR
composer lint:fix
# Run a single test file
./vendor/bin/phpunit tests/Request/SaslHandshakeRequestV1Test.php
# Run a single test method
./vendor/bin/phpunit --filter testSerializesCorrectly
# Run with verbose output
./vendor/bin/phpunit --testdoxTests live in tests/ with PSR-4 autoloading under CrazyGoat\RabbitStream\Tests\, mirroring the src/ structure:
tests/Buffer/— ReadBuffer, WriteBuffer teststests/Request/— serialization tests for each request classtests/Response/— deserialization tests for each response classtests/E2E/— integration tests against real RabbitMQ (via Docker)
The run-e2e.sh script starts RabbitMQ via docker compose, waits for it to be healthy, runs the e2e suite, and shuts down the container. E2E tests respect RABBITMQ_HOST and RABBITMQ_PORT env vars (default: 127.0.0.1:5552) and RABBITMQ_MANAGEMENT_PORT (default: 15672).
composer phpstan # static analysis (PHPStan level 9)
composer cs # check code style (PHPCS PSR-12)
composer cs-fix # auto-fix code style (phpcbf)
composer rector # preview refactoring suggestions (dry-run)
composer rector:fix # apply Rector refactoring
composer kb-lint # lint the docs/helpers/ knowledge base (runs inside `lint`)
composer kb-lint:fix # regenerate the docs/helpers/ tag index
composer test:suite-coverage # fail if a tests/**/*Test.php is outside every phpunit.xml suiteA pre-push hook runs bin/lint.sh (PHPCS + Rector dry-run + PHPStan level 9
kb-lint+ docs link check + test suite coverage + shellcheck) before every push. Git does not version.git/hooks/, so install it after a fresh clone:
bash bin/install-hooks.sh # symlinks bin/hooks/* into .git/hooks/Bypass in an emergency with git push --no-verify; CI runs the same
bin/lint.sh, so skipping locally only moves the failure to CI. See bin/README.md.
.github/workflows/ci.yml ends in the ci-ok job, the only required check. Documentation-only
changes skip the heavy jobs. There is no coverage gate; do not add one. Workflows of fork pull
requests need a maintainer's approval (repository setting "Require approval for fork pull request
workflows"). A maintainer must read the changes to .github/workflows/ (and anything CI executes,
such as composer.json scripts) before approving the run.
src/
├── Buffer/ # ReadBuffer, WriteBuffer, interfaces
├── Enum/ # KeyEnum (protocol command keys), ResponseCodeEnum
├── Request/ # Client-sent command classes (*RequestV1.php)
├── Response/ # Server-sent response classes (*ResponseV1.php)
├── Contract/ # Interfaces (CorrelationInterface, KeyVersionInterface)
├── Trait/ # Shared traits (CorrelationTrait, V1Trait, CommandTrait)
├── VO/ # Value Objects (KeyValue)
├── ResponseBuilder.php # Static dispatcher: raw buffer → typed response object
└── StreamConnection.php # TCP socket connection management
examples/ # Working usage examples
- Request classes:
{CommandName}RequestV1.php(e.g.SaslHandshakeRequestV1) - Response classes:
{CommandName}ResponseV1.php(e.g.OpenResponseV1) - Suffix
V1indicates protocol version 1; future versions useV2, etc. - Enums:
{Name}Enum(e.g.KeyEnum,ResponseCodeEnum) - Traits:
{Name}Trait(e.g.CorrelationTrait,V1Trait) - Interfaces:
{Name}Interface(e.g.CorrelationInterface,KeyVersionInterface)
public static functionordering per PSR-12- Static factory methods on response/request classes:
fromStreamBuffer(ReadBuffer $buffer): ?object - Fluent builder methods on
WriteBuffer: alladd*()returnself
SCREAMING_SNAKE_CASEfor enum cases (e.g.KeyEnum::DECLARE_PUBLISHER)- Hex literals for protocol key values (e.g.
0x0001,0x8011)
- PHP 8.1+ backed enums (
enum KeyEnum: int) - Constructor property promotion
matchexpressions withdefault => throw new \Exception(...)pattern- Named arguments where appropriate
- Nullsafe operator and null coalescing where applicable
- Always use
usestatements; never use fully-qualified class names inline - Group imports: standard library first, then project namespaces alphabetically
- No unused imports
- Always declare parameter types and return types on public methods
?Typefor nullable; avoidmixedunless unavoidablevoidreturn type on methods that return nothingint,string,bool— always use lowercase scalar types
- Throw a specific class from
src/Exception/— never bare\Exception(see DEC-002 anddocs/en/guide/error-handling.md) catch (RabbitStreamExceptionInterface)covers every library throwable;catch (RabbitStreamException)does not coverInvalidArgumentException/LengthException, which shadow native types- Return
nullfromfromStreamBuffer()when parsing fails gracefully assertResponseCodeOk()inCommandTraitthrowsProtocolExceptionon non-OK response codes
Every protocol command needs up to 4 things.
<?php
namespace CrazyGoat\RabbitStream\Request;
use CrazyGoat\RabbitStream\Buffer\ToStreamBufferInterface;
use CrazyGoat\RabbitStream\Buffer\WriteBuffer;
use CrazyGoat\RabbitStream\Enum\KeyEnum;
use CrazyGoat\RabbitStream\Contract\CorrelationInterface;
use CrazyGoat\RabbitStream\Contract\KeyVersionInterface;
use CrazyGoat\RabbitStream\Trait\CommandTrait;
use CrazyGoat\RabbitStream\Trait\CorrelationTrait;
use CrazyGoat\RabbitStream\Trait\V1Trait;
class ExampleRequestV1 implements ToStreamBufferInterface, CorrelationInterface, KeyVersionInterface
{
use CorrelationTrait;
use V1Trait;
use CommandTrait;
public function __construct(private string $stream) {}
public function toStreamBuffer(): WriteBuffer
{
return self::getKeyVersion($this->getCorrelationId())
->addString($this->stream);
}
public static function getKey(): int
{
return KeyEnum::EXAMPLE->value;
}
}<?php
namespace CrazyGoat\RabbitStream\Response;
use CrazyGoat\RabbitStream\Buffer\FromStreamBufferInterface;
use CrazyGoat\RabbitStream\Buffer\ReadBuffer;
use CrazyGoat\RabbitStream\Enum\KeyEnum;
use CrazyGoat\RabbitStream\Contract\CorrelationInterface;
use CrazyGoat\RabbitStream\Contract\KeyVersionInterface;
use CrazyGoat\RabbitStream\Trait\CommandTrait;
use CrazyGoat\RabbitStream\Trait\CorrelationTrait;
use CrazyGoat\RabbitStream\Trait\V1Trait;
class ExampleResponseV1 implements KeyVersionInterface, CorrelationInterface, FromStreamBufferInterface
{
use CorrelationTrait;
use CommandTrait;
use V1Trait;
public static function fromStreamBuffer(ReadBuffer $buffer): ?object
{
self::validateKeyVersion($buffer->getUint16(), $buffer->getUint16());
$correlationId = $buffer->getUint32();
self::assertResponseCodeOk($buffer->getUint16());
$object = new self();
$object->withCorrelationId($correlationId);
return $object;
}
public static function getKey(): int
{
return KeyEnum::EXAMPLE_RESPONSE->value;
}
}Add both request and response keys:
case EXAMPLE = 0x00xx;
case EXAMPLE_RESPONSE = 0x80xx;Add to the match in getV1():
KeyEnum::EXAMPLE_RESPONSE => ExampleResponseV1::fromStreamBuffer($responseBuffer),The generic flow is in docs/workflow.md. On top of it:
- Branches are
<type>/issue-<N>-<slug>, created bybin/worktree.sh <N>. Never work directly onmain; it is protected and needs a greenci-ok. - A PR that implements a protocol command also changes
README.md:❌→✅in the Protocol Implementation Status table. - Run
bin/lint.sh && composer test:unitbefore the review. Run./run-e2e.shonly for wire-level changes (it needs Docker). - An extra-careful review (re-check against the protocol spec) is required when the
diff touches
src/StreamConnection.php(thesocket_selectloop, server-push dispatch, heartbeat echo,ConsumerUpdatereply), wire-format code (*RequestV1,*ResponseV1,Buffer/),ResponseBuilderdispatch orKeyEnumvalues, security-relevant socket and parsing code, a public API (Connection,Producer,Consumer), or more than 200 changed lines. - After about four review rounds stop iterating and decide: narrow the issue, re-plan, or ask.
- Never lower a gate (a linter rule, the PHPStan level) to make a round look clean.
- Follow-up findings that cannot be automated may go to the knowledge base in
docs/helpers/(see below).
docker-compose.yml publishes the broker on ${RABBITMQ_PORT:-5552} (stream),
${RABBITMQ_AMQP_PORT:-5672} and ${RABBITMQ_MANAGEMENT_PORT:-15672}. bin/worktree.sh
writes free ports and a unique COMPOSE_PROJECT_NAME to .env.worktree; ./run-e2e.sh
loads that file when it exists. E2E tests read RABBITMQ_HOST, RABBITMQ_PORT and
RABBITMQ_MANAGEMENT_PORT. bin/worktree-setup.sh runs composer install;
bin/worktree-teardown.sh runs docker compose down -v.
A single-writer knowledge base of pitfalls (faq.md, FAQ-NNN) and decisions
(decisions.md, DEC-NNN). Read the generated tag index at the top of each file, pick the tags
that match your diff, and read only those entries. Coders and reviewers only propose
entries in their report; the person who runs the follow-up step (step 7 of the workflow)
writes them. Prefer a test or lint rule over an entry. composer kb-lint validates the files
and composer kb-lint:fix regenerates the index. Format: docs/helpers/README.md.
Some frames are sent Server → Client without a correlation ID — they are not responses to a specific request. These require a readLoop() dispatcher in StreamConnection, not a simple readMessage() call.
| Key | Command | Routed by | Notes |
|---|---|---|---|
0x0003 |
PublishConfirm | publisherId |
Async confirm after Publish |
0x0004 |
PublishError | publisherId |
Async error after Publish |
0x0008 |
Deliver | subscriptionId |
Message delivery to consumer |
0x0010 |
MetadataUpdate | stream name | Stream topology changed |
0x0017 |
Heartbeat | — | Must echo back immediately |
0x001a |
ConsumerUpdate | subscriptionId |
Server asks client for offset; client must reply |
Rule: PublishConfirm and PublishError use the request key (0x0003/0x0004), NOT the response key (0x8003/0x8004). Same for Deliver (0x0008), MetadataUpdate (0x0010), Heartbeat (0x0017), ConsumerUpdate (0x001a).
The readLoop() implementation is done — see src/StreamConnection.php.
readMessage() uses an internal loop with socket_select() to handle server-push frames transparently:
readMessage():
while (true):
wait for data via socket_select()
frame = readFrame()
if frame.key is server-push (0x0003/0x0004/0x0008/0x0010/0x0017/0x001a):
dispatch(frame) → call registered callback, echo heartbeat, etc.
continue → keep reading
else:
return frame → give caller the response they were waiting for
This mirrors how Go/Java clients work (dedicated goroutine/thread reading all frames), but in single-threaded PHP using socket_select() instead. Callers of readMessage() never see server-push frames — they are handled transparently inside the loop.
Consequence: existing tests do NOT need to change. readMessage() still returns the expected response type; it just silently handles any server-push frames that arrive before it.
For publishing/consuming where the caller wants to drive the loop themselves:
$connection->registerPublisher(1, onConfirm: fn($ids) => ..., onError: fn($errs) => ...);
$connection->sendMessage(new PublishRequestV1(...));
$connection->readLoop(maxFrames: 1); // blocks until 1 server-push frame dispatchedreadLoop() also uses socket_select() internally and dispatches all frames to callbacks.
These exist in the current codebase — do not replicate them in new code:
No custom exceptions — use✅ Fixed in #242\Exceptionuntil an exception hierarchy is introduced.
Full protocol spec: https://github.com/rabbitmq/rabbitmq-server/blob/main/deps/rabbitmq_stream/docs/PROTOCOL.adoc
Frame structure:
Frame => Size (uint32) + Payload
Payload => Key (uint16) + Version (uint16) + [CorrelationId (uint32)] + Content
Response key = Request key | 0x8000
All integers are big-endian. Strings are int16-length-prefixed UTF-8. Bytes are int32-length-prefixed (-1 = null).