irongit/symfony2-stream-response
A Symfony2 bundle that provides streamed HTTP responses, letting you push large or generated content to the client incrementally instead of buffering it all in memory. Useful for big file downloads, exports, and long-running output where streaming improves performance and reduces memory use.
Step 0 — the reality check that saves you a sprint. irongit/symfony2-stream-response is a Symfony 2 bundle, not a Composer library you can require into a Laravel app. It ships a Response subclass plus bundle/DI scaffolding registered via AppKernel::registerBundles() — a mechanism Laravel does not have. Laravel pulls Symfony in only at the component level (symfony/http-foundation via illuminate/http), and the version there is ≥4.x, whose HttpFoundation API diverges from the Symfony2-era surface this package targets. Installing it would produce an API-incompatible duplicate of a class you already have.
Step 1 — look here first, in this order:
app/Http/Controllers/ — find the endpoint that's OOMing or timing out (exports, reports, media).routes/web.php / routes/api.php — confirm the route, its middleware, and whether it's behind auth/rate limits.Illuminate\Routing\ResponseFactory — stream(), streamDownload(), file(), download(). This is your entire "package."config/filesystems.php — if the bytes already live on S3/local disk, you may not need PHP streaming at all.Step 2 — first use case: replace a buffered export. The canonical day-one task is a large CSV/report export that currently does response()->json($hugeArray) or builds a string in memory. The native one-liner:
// app/Http/Controllers/ExportController.php
public function export(): StreamedResponse
{
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'email', 'created_at']);
App\Models\User::query()->lazyById(1000)->each(function ($user) use ($out) {
fputcsv($out, [$user->id, $user->email, $user->created_at]);
});
fclose($out);
}, 'users-' . now()->format('Ymd') . '.csv', [
'Content-Type' => 'text/csv',
]);
}
That is functionally what the package provides, minus the abandonment risk. If you want the package's genuinely reusable insight — closing the session and clearing output buffers before the first byte goes out — read its Response class source, port those ~10 lines into app/Support/StreamedDownloadResponse.php, and move on. Do not add a composer.json entry.
Where to stop: if your use case is "serve a file from disk," you're done before you start — response()->file($path) (Symfony's BinaryFileResponse) already gives you Range, ETag, and Last-Modified handling that this package almost certainly lacks.
Translation table — package concept → Laravel primitive:
| Package behaviour | Laravel equivalent |
|---|---|
StreamedResponse subclass with a callback |
response()->stream($cb) / response()->streamDownload($cb, $name) |
| Chunked file send loop | response()->file($path) (BinaryFileResponse, range-capable) |
Forced download with Content-Disposition |
response()->download($path, $name, $headers) |
Custom Content-Type/headers on a stream |
Second/third args to streamDownload(), or ->headers->set() on a StreamedResponse |
Manual ob_flush()/flush() chunking |
StreamedResponse + ob_end_clean(); Laravel handles the send loop |
| Session close before streaming | session()->save() (or ->close() on old Laravel) at the top of the callback |
Pattern 1 — generated content, always lazy/cursor, never get(). The stream callback must be pull-based. Iterating an eager-loaded collection defeats the purpose: memory is consumed before the first chunk.
User::query()->select('id','email')->lazyById(500)->each(fn ($u) => fputcsv($out, [(array) $u]));
Pattern 2 — disk files: pre-check, then response()->file().
abort_unless(Storage::disk('exports')->exists($path), 404);
return response()->file(Storage::disk('exports')->path($path));
Prefer file() over a hand-rolled fread loop — you get conditional requests (If-None-Match, If-Modified-Since), 206 Partial Content, and Accept-Ranges for free, which this package does not reliably give you.
Pattern 3 — raw StreamedResponse when you need byte-level control (SSE, custom flush intervals, non-standard headers). Build it manually rather than wrapping anything:
return new StreamedResponse(function () use ($job) {
while (ob_get_level() > 0) { ob_end_flush(); }
foreach ($job->chunks() as $chunk) {
echo $chunk;
flush();
}
}, 200, ['Content-Type' => 'application/x-ndjson', 'X-Accel-Buffering' => 'no']);
Pattern 4 — middleware for cross-cutting download concerns. Auth, per-user concurrency caps, and audit logging belong in middleware, not in the response construction. This is also where you bound the blast radius of a slow client pinning a PHP-FPM worker:
// app/Http/Middleware/LimitConcurrentStreams.php
RateLimiter::for('downloads', fn (Request $r) => Limit::perMinute(10)->by($r->user()->id));
Pattern 5 — offload before you stream. If the file is on disk behind Nginx, or in S3/GCS, PHP should never touch the bytes:
// Nginx internal redirect — zero PHP bytes, zero worker time
return response('', 200, ['X-Accel-Redirect' => "/protected/{$path}"]);
// or
return redirect(Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5)));
Pattern 6 — integrate with the existing pipeline, don't bypass it. A StreamedResponse is still a Symfony\Component\HttpFoundation\Response; it flows through Illuminate\Foundation\Http\Kernel middleware normally, so auth, can:, throttle, and LogRequests all still apply. The one thing you should not do is attach global middleware that buffers or decorates the body (compression, dd(), debug toolbars).
200 is already committed — the client receives a short file with a success status. Log completion inside the callback (Log::info('stream.complete', ...)) so you can distinguish "aborted" from "delivered."?>, a debug middleware calling ob_start(), or a dd() upstream corrupts the byte stream — or worse, silently buffers the whole payload, turning "streaming" into an OOM. Start the callback with while (ob_get_level() > 0) { ob_end_clean(); } and keep php.ini output_buffering = Off for streaming routes.session()->save() at the top of the callback, every concurrent stream holds the session lock and your app serializes. This is the one behaviour from the package genuinely worth porting.Content-Length = chunked transfer = proxy buffering. Nginx buffers the full response by default, converting your streaming design into a memory spike at the edge with no application-side symptom. Set proxy_buffering off; and send X-Accel-Buffering: no for the route.fastcgi_read_timeout deliberately, not by accident. A default 60s cut mid-export produces a truncated file that looks like a code bug.ignore_user_abort defaults off. After a disconnect, your loop keeps writing to a dead socket until PHP notices. Check connection_aborted() inside long loops if you care.memory_get_peak_usage(true), not wall-clock. A "streaming" endpoint that pegs peak memory at the size of the export is silently buffering. Assert in a test that peak memory stays flat as row count doubles.@ suppression, likely PSR-0 layout that Composer 2 won't autoload without a classmap), plus implicit-nullable and dynamic-property breakage on PHP 8.x. Port the ideas (buffer clear, session close, chunk flush), not the files, and license-check the irongit fork's provenance against toretto460 upstream first.app/Support/ behind an interface, register it in a service provider, and test it. That's the maintainable seam — it lets you swap in a queue-backed export or an S3 pre-signed URL later without touching controllers.BinaryFileResponse does, raise it as an engineering task and write it in app/Support/ — it's 50–100 lines you own, with tests, and no CVE feed you're missing.How can I help you explore Laravel packages today?