itul/buffer
itul/buffer is a lightweight buffering utility for Laravel and PHP applications. It collects items or deferred work and flushes them in controlled batches, helping reduce overhead, smooth background processing, and simplify batching.
composer require itul/buffer, then check composer show itul/buffer for the service provider it registers. In Laravel 9+ it should auto-discover; on older versions add the provider to config/app.php. If a config/buffer.php exists after php artisan vendor:publish --tag=buffer-config, skim it before doing anything else — buffer size, flush strategy, and "auto flush" flags are the usual knobs.ob_start() / ob_flush() / flush() dance plus whatever header hints the browser and proxy need. Ten minutes in src/ will tell you whether it exposes a facade, a helper, or a middleware, and exactly which INI settings it sets (zlib.output_compression, output_buffering, implicit_flush). That matters more than the docs.echo output (the output_buffering INI, plus Laravel's response lifecycle) and ships it in one shot when the request ends. For a 90-second import, the user stares at a spinner. This package forces bytes out incrementally so the browser can paint progress.fetch() + ReadableStream (or an <iframe> for legacy). Sketch:Route::get('/imports/{import}/progress', function (Import $import) {
$buffer = app(\Itul\Buffer\Buffer::class); // or the facade/helper it ships
return response()->stream(function () use ($import, $buffer) {
while (! $import->fresh()->isFinished()) {
echo json_encode(['done' => $import->processed, 'total' => $import->total])."\n";
$buffer->flush(); // however the package names this
sleep(1);
}
}, 200, [
'Content-Type' => 'application/x-ndjson',
'Cache-Control' => 'no-cache, no-transform',
'X-Accel-Buffering' => 'no',
]);
});
Get that working locally under php artisan serve and behind your real Nginx/Apache before you build UI on top of it.
Stream from the web process only when the work is short. Two viable architectures:
processed/total/stage to the cache or a imports table row, and have the stream endpoint poll that row and echo a line per tick. This survives max_execution_time, retries, and lets the user close the tab without orphaning work. Prefer this the moment the task can exceed a minute.Use Laravel's response()->stream() as the outer shell. It sets Transfer-Encoding: chunked and gives you a closure to write into. The package handles the per-iteration flush; don't mix in your own ob_start() calls on top or you'll fight it.
Emit newline-delimited JSON, not HTML fragments. NDJSON is trivial to parse incrementally on the client, plays well with TextDecoderStream, and lets you add fields later without breaking the reader. Send a periodic heartbeat line ({"ping":true}) every few seconds during quiet phases so proxies and load balancers don't reap the connection.
Disable response-compressing middleware for these routes. Laravel's CompressResponse (and any mod_deflate/gzip at the edge) will re-buffer everything you just flushed. Exclude the progress route by path in bootstrap/app.php or via a route group with the middleware removed, and confirm with curl -N that chunks arrive at roughly one-second intervals rather than all at once at the end.
Artisan side. The same package is handy for long console commands — progress bars already flush, but if you're writing custom $this->output->write() loops with \n and want them visible when the command is invoked over HTTP (rare) or piped, the package's flush helper keeps behaviour consistent across SAPI and CLI.
Testing strategy. Don't unit-test the stream. Assert on the state the stream reads (the job, the cache keys) with a normal feature test, and cover the transport with one integration test that boots php artisan serve or hits the staging URL and asserts the first chunk arrives before a sleep. Mocking flush() proves nothing.
X-Accel-Buffering: no is usually enough; if not, you're editing fastcgi_buffering off; in the vhost. This is the single most common "it works locally, hangs in prod" cause.zlib.output_compression = On in php.ini silently undoes every flush. So does output_buffering = 4096 plus a package that only flushes its own nested level. Verify with php -i | grep -E 'output_buffering|zlib'.session_write_close() (or $request->session()->save()) before entering the loop — for the import-triggering request and the stream.?>, a debug dump() in a middleware, or an appended debugbar/Telescope payload will send headers and break the chunked response. Turn Debugbar and Telescope off for these routes.try/finally. If the package opens a buffer and your code throws mid-stream, an unclosed ob_start() swallows the error output and you get a blank 200. Explicitly close buffered levels in finally and log the exception to a file rather than the response.connection_aborted() / ignore_user_abort() deliberately. Decide whether closing the tab should stop the work (then check connection_aborted() each tick and bail) or let it run (then ignore_user_abort(true) and don't rely on the request to finish the job — push it to the queue instead).set_time_limit(0) and know your web server's fastcgi_read_timeout. sleep() loops plus a 60-second proxy timeout equals a truncated stream with no error. Heartbeats help; a queue-based design sidesteps the whole class of problem.response()->download() or a signed temporary URL.swap a no-op implementation in tests), whether the buffer level is configurable, and whether it registers middleware you can opt out of per-route. If the package calls ob_flush() unconditionally, you'll see "failed to flush buffer" notices when nothing is buffered — guard your local dev with while (ob_get_level()) { ob_end_flush(); } if the noise gets annoying.composer.json and keep the wrapper confined to one or two classes in your app so you can swap in a plain flush() loop if maintenance stops.How can I help you explore Laravel packages today?