crazy-goat/workerman-bundle
Symfony bundle integrating Workerman to run a high-performance async HTTP server, scheduler and supervisor in pure PHP. Keeps the Symfony kernel/container alive between requests for faster apps. Supports SO_REUSEPORT and optional direct Request creation for speed.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extract response conversion logic from SymfonyController into a dedicated ResponseConverter using Strategy Pattern with DI support.
Architecture: Strategy Pattern with ResponseConverter orchestrator and three concrete strategies (BinaryFile, StreamedResponse, Default). Strategies registered via DI with priority-based selection.
Tech Stack: PHP 8.2+, Symfony DI, PHPUnit, Workerman
New files to create:
src/Http/Response/ResponseConverterStrategy.php - Interfacesrc/Http/Response/ResponseConverter.php - Main orchestratorsrc/Http/Response/Strategy/BinaryFileResponseStrategy.phpsrc/Http/Response/Strategy/StreamedResponseStrategy.phpsrc/Http/Response/Strategy/DefaultResponseStrategy.phptests/ResponseConverterTest.phptests/Strategy/BinaryFileResponseStrategyTest.phptests/Strategy/StreamedResponseStrategyTest.phptests/Strategy/DefaultResponseStrategyTest.phpFiles to modify:
src/Middleware/SymfonyController.php - Use ResponseConvertersrc/config/services.php - Register strategies with DIFiles:
Create: src/Http/Response/ResponseConverterStrategy.php
Step 1: Write the interface
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Http\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Workerman\Protocols\Http\Response as WorkermanResponse;
interface ResponseConverterStrategy
{
/**
* Check if this strategy can handle the given response.
*/
public function supports(SymfonyResponse $response): bool;
/**
* Convert Symfony response to Workerman response.
*
* [@param](https://github.com/param) array<string, list<string|null>> $headers Pre-extracted headers
*/
public function convert(SymfonyResponse $response, array $headers): WorkermanResponse;
}
Run: php -l src/Http/Response/ResponseConverterStrategy.php
Expected: No syntax errors
git add src/Http/Response/ResponseConverterStrategy.php
git commit -m "feat: add ResponseConverterStrategy interface"
Files:
Create: src/Http/Response/Strategy/DefaultResponseStrategy.php
Create: tests/Strategy/DefaultResponseStrategyTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Test\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\DefaultResponseStrategy;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class DefaultResponseStrategyTest extends TestCase
{
public function testSupportsAlwaysReturnsTrue(): void
{
$strategy = new DefaultResponseStrategy();
$this->assertTrue($strategy->supports(new Response()));
$this->assertTrue($strategy->supports(new JsonResponse(['test'])));
}
public function testConvertReturnsWorkermanResponseWithContent(): void
{
$strategy = new DefaultResponseStrategy();
$symfonyResponse = new Response('Hello World', 200, ['Content-Type' => 'text/plain']);
$workermanResponse = $strategy->convert($symfonyResponse, ['Content-Type' => ['text/plain']]);
$this->assertSame(200, $workermanResponse->getStatusCode());
$this->assertSame('Hello World', (string) $workermanResponse->rawBody());
}
public function testConvertHandlesEmptyContent(): void
{
$strategy = new DefaultResponseStrategy();
$symfonyResponse = new Response();
$workermanResponse = $strategy->convert($symfonyResponse, []);
$this->assertSame(200, $workermanResponse->getStatusCode());
$this->assertSame('', (string) $workermanResponse->rawBody());
}
}
Run: php vendor/bin/phpunit tests/Strategy/DefaultResponseStrategyTest.php -v
Expected: FAIL with "Class not found"
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Http\Response\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverterStrategy;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Workerman\Protocols\Http\Response as WorkermanResponse;
class DefaultResponseStrategy implements ResponseConverterStrategy
{
public function supports(SymfonyResponse $response): bool
{
return true;
}
public function convert(SymfonyResponse $response, array $headers): WorkermanResponse
{
return new WorkermanResponse(
$response->getStatusCode(),
$headers,
strval($response->getContent())
);
}
}
Run: php vendor/bin/phpunit tests/Strategy/DefaultResponseStrategyTest.php -v
Expected: PASS (3 tests)
git add src/Http/Response/Strategy/DefaultResponseStrategy.php tests/Strategy/DefaultResponseStrategyTest.php
git commit -m "feat: add DefaultResponseStrategy with tests"
Files:
Create: src/Http/Response/Strategy/StreamedResponseStrategy.php
Create: tests/Strategy/StreamedResponseStrategyTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Test\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\StreamedResponseStrategy;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class StreamedResponseStrategyTest extends TestCase
{
public function testSupportsReturnsTrueForStreamedResponse(): void
{
$strategy = new StreamedResponseStrategy();
$this->assertTrue($strategy->supports(new StreamedResponse()));
}
public function testSupportsReturnsTrueForStreamedJsonResponse(): void
{
$strategy = new StreamedResponseStrategy();
$this->assertTrue($strategy->supports(new StreamedJsonResponse(['data'])));
}
public function testSupportsReturnsFalseForBinaryFileResponse(): void
{
$strategy = new StreamedResponseStrategy();
$tmpFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile, 'test');
try {
$this->assertFalse($strategy->supports(new BinaryFileResponse($tmpFile)));
} finally {
unlink($tmpFile);
}
}
public function testSupportsReturnsFalseForRegularResponse(): void
{
$strategy = new StreamedResponseStrategy();
$this->assertFalse($strategy->supports(new \Symfony\Component\HttpFoundation\Response()));
}
public function testConvertCapturesOutputBuffer(): void
{
$strategy = new StreamedResponseStrategy();
$streamedResponse = new StreamedResponse(function () {
echo 'streamed content';
});
$workermanResponse = $strategy->convert($streamedResponse, ['Content-Type' => ['text/plain']]);
$this->assertSame(200, $workermanResponse->getStatusCode());
$this->assertSame('streamed content', (string) $workermanResponse->rawBody());
}
public function testConvertHandlesEmptyStream(): void
{
$strategy = new StreamedResponseStrategy();
$streamedResponse = new StreamedResponse(function () {
// Empty callback
});
$workermanResponse = $strategy->convert($streamedResponse, []);
$this->assertSame(200, $workermanResponse->getStatusCode());
$this->assertSame('', (string) $workermanResponse->rawBody());
}
public function testConvertPreservesStatusCode(): void
{
$strategy = new StreamedResponseStrategy();
$streamedResponse = new StreamedResponse(function () {
echo 'data';
}, 201);
$workermanResponse = $strategy->convert($streamedResponse, []);
$this->assertSame(201, $workermanResponse->getStatusCode());
}
}
Run: php vendor/bin/phpunit tests/Strategy/StreamedResponseStrategyTest.php -v
Expected: FAIL with "Class not found"
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Http\Response\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverterStrategy;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Workerman\Protocols\Http\Response as WorkermanResponse;
class StreamedResponseStrategy implements ResponseConverterStrategy
{
public function supports(SymfonyResponse $response): bool
{
return $response instanceof StreamedResponse
&& !$response instanceof BinaryFileResponse;
}
public function convert(SymfonyResponse $response, array $headers): WorkermanResponse
{
/** [@var](https://github.com/var) StreamedResponse $response */
ob_start();
$response->sendContent();
$content = ob_get_clean();
return new WorkermanResponse(
$response->getStatusCode(),
$headers,
$content
);
}
}
Run: php vendor/bin/phpunit tests/Strategy/StreamedResponseStrategyTest.php -v
Expected: PASS (6 tests)
git add src/Http/Response/Strategy/StreamedResponseStrategy.php tests/Strategy/StreamedResponseStrategyTest.php
git commit -m "feat: add StreamedResponseStrategy with tests"
Files:
Create: src/Http/Response/Strategy/BinaryFileResponseStrategy.php
Create: tests/Strategy/BinaryFileResponseStrategyTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Test\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\BinaryFileResponseStrategy;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class BinaryFileResponseStrategyTest extends TestCase
{
private string $testFile;
protected function setUp(): void
{
$this->testFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($this->testFile, 'test file content');
}
protected function tearDown(): void
{
if (file_exists($this->testFile)) {
unlink($this->testFile);
}
}
public function testSupportsReturnsTrueForBinaryFileResponse(): void
{
$strategy = new BinaryFileResponseStrategy();
$this->assertTrue($strategy->supports(new BinaryFileResponse($this->testFile)));
}
public function testSupportsReturnsFalseForRegularResponse(): void
{
$strategy = new BinaryFileResponseStrategy();
$this->assertFalse($strategy->supports(new Response()));
}
public function testSupportsReturnsFalseForStreamedResponse(): void
{
$strategy = new BinaryFileResponseStrategy();
$this->assertFalse($strategy->supports(new StreamedResponse()));
}
public function testConvertUsesWithFile(): void
{
$strategy = new BinaryFileResponseStrategy();
$binaryResponse = new BinaryFileResponse($this->testFile, 200, [], false, 'inline');
$workermanResponse = $strategy->convert($binaryResponse, ['Content-Type' => ['text/plain']]);
$this->assertSame(200, $workermanResponse->getStatusCode());
// withFile() sets internal file path, we can't directly test it but response should be valid
$this->assertInstanceOf(\Workerman\Protocols\Http\Response::class, $workermanResponse);
}
public function testConvertPreservesStatusCode(): void
{
$strategy = new BinaryFileResponseStrategy();
$binaryResponse = new BinaryFileResponse($this->testFile, 404);
$workermanResponse = $strategy->convert($binaryResponse, []);
$this->assertSame(404, $workermanResponse->getStatusCode());
}
public function testConvertPassesOffsetAndMaxlen(): void
{
$strategy = new BinaryFileResponseStrategy();
$binaryResponse = new BinaryFileResponse($this->testFile);
$binaryResponse->setOffset(10);
$binaryResponse->setMaxlen(100);
$workermanResponse = $strategy->convert($binaryResponse, []);
// Just verify it doesn't throw - withFile() is called with these params
$this->assertInstanceOf(\Workerman\Protocols\Http\Response::class, $workermanResponse);
}
}
Run: php vendor/bin/phpunit tests/Strategy/BinaryFileResponseStrategyTest.php -v
Expected: FAIL with "Class not found"
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Http\Response\Strategy;
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverterStrategy;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Workerman\Protocols\Http\Response as WorkermanResponse;
class BinaryFileResponseStrategy implements ResponseConverterStrategy
{
public function supports(SymfonyResponse $response): bool
{
return $response instanceof BinaryFileResponse;
}
public function convert(SymfonyResponse $response, array $headers): WorkermanResponse
{
/** [@var](https://github.com/var) BinaryFileResponse $response */
$workermanResponse = new WorkermanResponse(
$response->getStatusCode(),
$headers
);
$workermanResponse->withFile(
$response->getFile()->getPathname(),
$response->getOffset(),
$response->getMaxlen()
);
return $workermanResponse;
}
}
Run: php vendor/bin/phpunit tests/Strategy/BinaryFileResponseStrategyTest.php -v
Expected: PASS (6 tests)
git add src/Http/Response/Strategy/BinaryFileResponseStrategy.php tests/Strategy/BinaryFileResponseStrategyTest.php
git commit -m "feat: add BinaryFileResponseStrategy with tests"
Files:
Create: src/Http/Response/ResponseConverter.php
Create: tests/ResponseConverterTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Test;
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverter;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\BinaryFileResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\DefaultResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\StreamedResponseStrategy;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class ResponseConverterTest extends TestCase
{
private string $testFile;
protected function setUp(): void
{
$this->testFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($this->testFile, 'test content');
}
protected function tearDown(): void
{
if (file_exists($this->testFile)) {
unlink($this->testFile);
}
}
public function testConvertUsesCorrectStrategyOrder(): void
{
// Order: BinaryFile (100) > Streamed (50) > Default (0)
$strategies = [
new BinaryFileResponseStrategy(),
new StreamedResponseStrategy(),
new DefaultResponseStrategy(),
];
$converter = new ResponseConverter($strategies);
// BinaryFileResponse should use BinaryFile strategy
$binaryResponse = new BinaryFileResponse($this->testFile);
$workermanResponse = $converter->convert($binaryResponse);
$this->assertInstanceOf(\Workerman\Protocols\Http\Response::class, $workermanResponse);
// StreamedResponse should use Streamed strategy
$streamedResponse = new StreamedResponse(function () {
echo 'streamed';
});
$workermanResponse = $converter->convert($streamedResponse);
$this->assertSame('streamed', (string) $workermanResponse->rawBody());
// Regular response should use Default strategy
$regularResponse = new Response('regular');
$workermanResponse = $converter->convert($regularResponse);
$this->assertSame('regular', (string) $workermanResponse->rawBody());
}
public function testConvertThrowsWhenNoStrategyFound(): void
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('No strategy found');
// Empty strategies array
$converter = new ResponseConverter([]);
$converter->convert(new Response());
}
public function testConvertPreservesHeaders(): void
{
$strategies = [new DefaultResponseStrategy()];
$converter = new ResponseConverter($strategies);
$response = new JsonResponse(['key' => 'value'], 200, ['X-Custom' => 'header']);
$workermanResponse = $converter->convert($response);
$this->assertSame(200, $workermanResponse->getStatusCode());
// Headers should be preserved (Workerman stores them differently)
}
public function testConvertHandlesIterableStrategies(): void
{
// Test with Generator (simulating DI tagged_iterator)
$generator = function () {
yield new DefaultResponseStrategy();
};
$converter = new ResponseConverter($generator());
$response = $converter->convert(new Response('test'));
$this->assertSame('test', (string) $response->rawBody());
}
}
Run: php vendor/bin/phpunit tests/ResponseConverterTest.php -v
Expected: FAIL with "Class not found"
<?php
declare(strict_types=1);
namespace CrazyGoat\WorkermanBundle\Http\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Workerman\Protocols\Http\Response as WorkermanResponse;
class ResponseConverter
{
/** [@var](https://github.com/var) ResponseConverterStrategy[] */
private readonly array $strategies;
public function __construct(iterable $strategies)
{
$this->strategies = iterator_to_array($strategies, false);
}
public function convert(SymfonyResponse $response): WorkermanResponse
{
$headers = $this->extractHeaders($response);
foreach ($this->strategies as $strategy) {
if ($strategy->supports($response)) {
return $strategy->convert($response, $headers);
}
}
throw new \LogicException(sprintf(
'No strategy found for response type: %s',
get_class($response)
));
}
/**
* [@return](https://github.com/return) array<string, list<string|null>>
*/
private function extractHeaders(SymfonyResponse $response): array
{
$headers = $response->headers->all();
// Fix header names (lowercase to proper case)
$fixHeaders = [
'content-type' => 'Content-Type',
'connection' => 'Connection',
'transfer-encoding' => 'Transfer-Encoding',
'server' => 'Server',
'content-disposition' => 'Content-Disposition',
'last-modified' => 'Last-Modified',
];
foreach ($fixHeaders as $lower => $proper) {
if (isset($headers[$lower])) {
$headers[$proper] = $headers[$lower];
unset($headers[$lower]);
}
}
return $headers;
}
}
Run: php vendor/bin/phpunit tests/ResponseConverterTest.php -v
Expected: PASS (5 tests)
git add src/Http/Response/ResponseConverter.php tests/ResponseConverterTest.php
git commit -m "feat: add ResponseConverter orchestrator with tests"
Files:
Modify: src/config/services.php
Step 1: Add DI configuration for strategies
Add to src/config/services.php (before the return statement or in appropriate place):
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverter;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\BinaryFileResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\DefaultResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\StreamedResponseStrategy;
// ResponseConverter strategies - priority matters! Higher = checked first
$services->set(BinaryFileResponseStrategy::class)
->tag('workerman.response_converter.strategy', ['priority' => 100]);
$services->set(StreamedResponseStrategy::class)
->tag('workerman.response_converter.strategy', ['priority' => 50]);
$services->set(DefaultResponseStrategy::class)
->tag('workerman.response_converter.strategy', ['priority' => 0]);
// Main ResponseConverter with injected strategies
$services->set(Respons...
How can I help you explore Laravel packages today?