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.
Date: 2026-04-04
Issue: #72 - Response conversion should be extracted to a dedicated ResponseConverter
Status: Approved
Extract inline response conversion logic from SymfonyController into a dedicated ResponseConverter class using the Strategy Pattern. This enables testable, extensible handling of different Symfony response types and unblocks implementation of #71 (SSE), #70 (BinaryFileResponse), and #69 (StreamedResponse).
Currently, SymfonyController::__invoke() contains inline response conversion:
return new Response(
$this->symfonyResponse->getStatusCode(),
$this->getHeaders($this->symfonyResponse),
strval($this->symfonyResponse->getContent()),
);
Issues:
Implement Strategy Pattern with:
ResponseConverter - orchestrator that selects and executes appropriate strategyResponseConverterStrategy interface - contract for all strategies┌─────────────────────────────────────────────────────────────┐
│ ResponseConverter │
│ (orchestrator) │
├─────────────────────────────────────────────────────────────┤
│ convert(SymfonyResponse): WorkermanResponse │
│ - iterates over strategies │
│ - first strategy where supports() returns true → convert() │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────────────┐ ┌──────────────┐
│ BinaryFile │ │ StreamedResponse │ │ Default │
│ Strategy │ │ Strategy │ │ Strategy │
├───────────────┤ ├──────────────────┤ ├──────────────┤
│ BinaryFileResp│ │ StreamedResponse │ │ (fallback) │
│ withFile() │ │ ob_start() │ │ getContent() │
│ Range support │ │ ob_get_clean() │ │ │
└───────────────┘ └──────────────────┘ └──────────────┘
│
▼
┌──────────────────┐
│ Future: SSE │
│ Strategy │
│ (EventStreamResp)│
└──────────────────┘
src/
└── Http/
└── Response/
├── ResponseConverter.php # Main orchestrator
├── ResponseConverterStrategy.php # Interface
└── Strategy/
├── BinaryFileResponseStrategy.php # Priority: 100
├── StreamedResponseStrategy.php # Priority: 50
└── DefaultResponseStrategy.php # Priority: 0
interface ResponseConverterStrategy
{
/**
* Check if this strategy can handle the given response.
*/
public function supports(\Symfony\Component\HttpFoundation\Response $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(
\Symfony\Component\HttpFoundation\Response $response,
array $headers
): \Workerman\Protocols\Http\Response;
}
Critical: Order matters due to class hierarchy!
BinaryFileResponse extends StreamedResponse
EventStreamResponse extends StreamedResponse
| Priority | Strategy | Handles | Why this priority? |
|---|---|---|---|
| 100 | BinaryFileResponseStrategy | BinaryFileResponse | Most specific - must be checked before parent StreamedResponse |
| 50 | StreamedResponseStrategy | StreamedResponse, StreamedJsonResponse | Parent class - checked after more specific children |
| 0 | DefaultResponseStrategy | Response, JsonResponse, etc. | Fallback for all other types |
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(\Symfony\Component\HttpFoundation\Response $response): \Workerman\Protocols\Http\Response
{
$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($response): array
{
$headers = $response->headers->all();
// Fix header names (from current SymfonyController)
$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;
}
}
class BinaryFileResponseStrategy implements ResponseConverterStrategy
{
public function supports($response): bool
{
return $response instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse;
}
public function convert($response, array $headers): \Workerman\Protocols\Http\Response
{
$workermanResponse = new \Workerman\Protocols\Http\Response(
$response->getStatusCode(),
$headers
);
$workermanResponse->withFile(
$response->getFile()->getPathname(),
$response->getOffset(),
$response->getMaxlen()
);
return $workermanResponse;
}
}
class StreamedResponseStrategy implements ResponseConverterStrategy
{
public function supports($response): bool
{
// Exclude BinaryFileResponse - it has higher priority
return $response instanceof \Symfony\Component\HttpFoundation\StreamedResponse
&& !$response instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse;
}
public function convert($response, array $headers): \Workerman\Protocols\Http\Response
{
ob_start();
$response->sendContent();
$content = ob_get_clean();
return new \Workerman\Protocols\Http\Response(
$response->getStatusCode(),
$headers,
$content
);
}
}
class DefaultResponseStrategy implements ResponseConverterStrategy
{
public function supports($response): bool
{
// Fallback - handles everything
return true;
}
public function convert($response, array $headers): \Workerman\Protocols\Http\Response
{
return new \Workerman\Protocols\Http\Response(
$response->getStatusCode(),
$headers,
strval($response->getContent())
);
}
}
// src/config/services.php
use CrazyGoat\WorkermanBundle\Http\Response\ResponseConverter;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\BinaryFileResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\StreamedResponseStrategy;
use CrazyGoat\WorkermanBundle\Http\Response\Strategy\DefaultResponseStrategy;
// Register strategies with priority (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 converter - injects all tagged strategies
$services->set(ResponseConverter::class)
->args([tagged_iterator('workerman.response_converter.strategy')]);
Before:
public function __invoke(Request $request): Response
{
$this->symfonyRequest = RequestConverter::toSymfonyRequest($request);
$this->kernel->boot();
$this->symfonyResponse = $this->kernel->handle($this->symfonyRequest);
$this->symfonyResponse->prepare($this->symfonyRequest);
return new Response(
$this->symfonyResponse->getStatusCode(),
$this->getHeaders($this->symfonyResponse),
strval($this->symfonyResponse->getContent()),
);
}
After:
public function __construct(
private readonly KernelInterface $kernel,
private readonly ResponseConverter $responseConverter,
) {
}
public function __invoke(Request $request): Response
{
$this->symfonyRequest = RequestConverter::toSymfonyRequest($request);
$this->kernel->boot();
$this->symfonyResponse = $this->kernel->handle($this->symfonyRequest);
$this->symfonyResponse->prepare($this->symfonyRequest);
return $this->responseConverter->convert($this->symfonyResponse);
}
BinaryFileResponseStrategyTest
supports() returns true only for BinaryFileResponseconvert() calls withFile() with correct parametersStreamedResponseStrategyTest
supports() returns true for StreamedResponse but NOT BinaryFileResponseconvert() captures output buffer correctlyDefaultResponseStrategyTest
supports() always returns trueconvert() uses getContent()ResponseConverterTest
This design enables easy addition of new response types:
Example: EventStreamResponseStrategy (for #71)
class EventStreamResponseStrategy implements ResponseConverterStrategy
{
public function supports($response): bool
{
return $response instanceof EventStreamResponse;
}
public function convert($response, array $headers): Response
{
// Special SSE handling with connection management
// Requires access to TcpConnection (architectural change)
}
}
Priority: 75 (between BinaryFile and StreamedResponse)
| Risk | Mitigation |
|---|---|
| Wrong strategy order breaks BinaryFileResponse | Document priority system clearly; add integration test |
| Performance overhead of iteration | Negligible (3-4 strategies max); can optimize later if needed |
| Breaking change for existing code | SymfonyController keeps same public API; only internal change |
How can I help you explore Laravel packages today?