audunru/export-response
A Laravel package for returning downloadable export responses, making it easy to turn query results or data sets into streamed file downloads such as CSV instead of building export responses by hand.
This package turns ordinary Laravel responses into downloadable exports (CSV, XLSX, etc.) by negotiating the format from the request's Accept header, so a single controller action can serve both HTML/JSON and a file download.
Install and (optionally) publish config:
composer require audunru/export-response
php artisan vendor:publish --tag=export-response-config
Register the export middleware on the routes that should be exportable — typically a route group rather than globally, so you don't accidentally convert HTML responses:
Route::middleware('export-response')->group(function () {
Route::get('/reports/orders', OrderReportController::class);
});
Look first at the package README for the supported Accept values and the config file for the default/fallback format. Those two things determine almost every behaviour you'll debug later.
First use case: a report endpoint that returns a collection or array from the controller as usual, then let the middleware serialize it. Verify it from the CLI before wiring up any frontend:
curl -H 'Accept: text/csv' https://app.test/reports/orders -o orders.csv
curl -H 'Accept: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' \
https://app.test/reports/orders -o orders.xlsx
If both produce sensible files, the integration is working; everything after that is column shaping and performance.
One endpoint, many formats. Keep the controller returning the same data structure and let the middleware pick the representation. This keeps the report logic in one place and makes the link-building trivial — the same URL with a different Accept header (or an explicit ?format=csv override, if the package supports it) is the download.
Format override for links and emails. Browsers send broad Accept headers (text/html,application/xhtml+xml,...), which rarely resolve cleanly. For user-facing "Download CSV" buttons, prefer an explicit query parameter or file extension so the behaviour is deterministic and the URL is self-describing.
Reuse existing query/filter layers. Because the controller still runs normally, you can keep your existing request validation, policies, and Eloquent filtering, and only the final serialization changes. This is the biggest day-to-day win: exports inherit authorization and scoping for free.
Testing. Assert on the response headers and body rather than parsing binaries:
$this->get('/reports/orders', ['Accept' => 'text/csv'])
->assertOk()
->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
For XLSX, assert the content type and that the body is non-empty; don't snapshot binary output.
Large exports. For anything beyond a few thousand rows, prefer a streaming/lazy approach (cursor or lazy()) so the export doesn't materialize the whole result set in memory. If the package supports chunked writers, use them; otherwise cap the export and point users at a queued export job.
Queue + storage for heavy reports. The middleware pattern is synchronous by design. For slow exports, generate the file in a job, store it, and return a signed URL — the middleware then only handles the small, fast reports.
Column shaping. Keep a dedicated export/DTO layer (or the package's formatter extension point) for headers, ordering, and value transformation. Avoid formatting dates/numbers inline in the controller; that's the first thing that drifts between formats.
Upgrade to 5.1.3 if you cache anything. Prior to this release the export middleware did not send Vary: Accept, meaning a shared cache (CDN, nginx proxy_cache, Varnish, some Laravel response cache setups) could store the CSV variant and serve it to a client requesting XLSX — or worse, serve a user's export from cache to another user. This is a genuine correctness and data-exposure bug, not a cosmetic header. After upgrading, purge any cached export responses and confirm the header is present:
curl -sI -H 'Accept: text/csv' https://app.test/reports/orders | grep -i vary
If your CDN ignores Vary, key the cache on the Accept header explicitly, or add Cache-Control: private, no-store to export responses.
Beware the default format. When Accept is */* (curl default, many HTTP clients) or a browser's HTML-heavy header, the middleware falls back to whatever the config specifies. A surprising number of "why did I get CSV in the browser?" tickets are this. Set the fallback deliberately and document the override parameter.
Don't apply the middleware globally. It rewrites responses based on request headers, so globally it can mangle JSON APIs (an API client sending Accept: text/csv by mistake gets a spreadsheet instead of a 406). Scope it to export routes.
Caching and auth interact badly. Export URLs often contain filters and are highly user-specific. Make sure they're behind auth/policies and are not stored in a shared cache — the Vary fix is necessary but not sufficient if you also need per-user isolation.
Excel and text encoding. CSV opened directly in Excel needs a UTF-8 BOM and CRLF line endings to render non-ASCII correctly; without it, accented characters and long numbers get mangled. Check what the package's CSV writer does before hand-rolling a formatter.
Filename and Content-Disposition. Set a meaningful filename with a timestamp, and prefer filename*=UTF-8''... for non-ASCII names. Users notice this immediately, and it's cheap to get right.
Excel injection. Any cell beginning with =, +, -, or @ can be interpreted as a formula. Sanitize user-supplied values in your formatter — a CSV export of user data is a classic injection vector.
Debugging negotiation. When the format is wrong, log the raw Accept header and the resolved format inside the middleware pipeline rather than guessing. Header values from proxies and API gateways are frequently rewritten, and the Vary fix is a good reminder that intermediaries are part of the equation.
Extension points. If you need a format the package doesn't ship, look for the format registry/macro hook in the config or service provider before subclassing the middleware — custom formats registered there survive package upgrades better than forked middleware.
How can I help you explore Laravel packages today?