cardinalby/content-disposition
A small PHP library for parsing and generating HTTP Content-Disposition headers, including attachment/inline types, quoted filenames, and RFC 5987/6266 filename* encoding for safe, browser-compatible file downloads.
This is a single-class, dependency-free library — no service provider, no config file, no facade. Installation is the whole setup:
composer require cardinalby/content-disposition
Nothing to register in config/app.php. The class autoloads via PSR-4 and is used directly.
Where to look first:
src/ContentDisposition.php — the entire package is one file; read it top to bottom in ~5 minutes. Do this before writing code, because the method names below are the shape of the API, not a guarantee of exact spelling.tests/ — the fastest way to see the exact expected header strings for edge cases like filename*=UTF-8''....First use case — the one that actually justifies the dependency: you are proxying or re-emitting a file and both produce and consume Content-Disposition headers. Laravel's response()->download() handles the produce side well, but nothing in the framework parses an incoming Content-Disposition header into a filename and disposition type. That parsing is the gap this fills:
use cardinalby\ContentDisposition\ContentDisposition;
$cd = ContentDisposition::parse($request->header('Content-Disposition'));
$cd->getType(); // 'attachment' | 'inline'
$cd->getFilename(); // decoded filename, honours filename*
If all you need is return response()->download($path, $name);, stop here — you don't need this package.
The common Laravel case: the file lives in S3 / is generated on the fly, so response()->download() (which wants a local path) is awkward, and you need a correct RFC 6266 header with both a legacy filename fallback and a UTF-8 filename*.
use cardinalby\ContentDisposition\ContentDisposition;
return response()->streamDownload(function () use ($stream) {
fpassthru($stream);
}, 'placeholder.pdf', [
'Content-Type' => 'application/pdf',
// replace the header Laravel generated with a fully RFC-compliant one
'Content-Disposition' => (string) ContentDisposition::createAttachment('Отчёт 2024.pdf'),
]);
Note the cast to (string) — see Gotchas.
Typical trigger: a webhook or an upstream API returns a file and tells you its name only via the header.
$filename = 'download.bin';
if ($header = $response->header('Content-Disposition')) {
$cd = ContentDisposition::parse($header);
$filename = $cd->getFilename() ?? $filename;
}
Wrap this in a small service or a PendingRequest macro so you're not repeating it in every controller:
// AppServiceProvider::boot()
Http::macro('filenameFrom', function ($response, string $default = 'download.bin') {
$header = $response->header('Content-Disposition');
return $header
? (ContentDisposition::parse($header)->getFilename() ?? $default)
: $default;
});
Because the parsed value survives __toString(), it is a clean fit for persisting the semantic fields rather than the raw string:
$cd = ContentDisposition::parse($request->header('Content-Disposition'));
Upload::create([
'original_name' => $cd->getFilename(),
'disposition' => $cd->getType(),
]);
Downside: if you need to replay the exact header later, store the raw string too — re-serialising can reorder or normalise parameters.
Combining 1 and 2, the pattern is parse → sanitise → re-emit:
$upstream = Http::withToken($token)->get($url);
$cd = ContentDisposition::parse($upstream->header('Content-Disposition', 'attachment'));
return response()->streamDownload(
fn () => print($upstream->body()),
$cd->getFilename() ?? 'file.bin',
['Content-Disposition' => (string) $cd]
);
For PDF preview / image display you want inline; for forcing a save you want attachment. Keep the choice at the call site, not buried in the class:
$header = $request->boolean('preview')
? ContentDisposition::createInline($name)
: ContentDisposition::createAttachment($name);
Assert on the final rendered string, not on object internals — that's what the browser sees:
$this->get('/export')
->assertHeader('Content-Disposition', "attachment; filename=\"report.pdf\"; filename*=UTF-8''report.pdf");
Copy the expected strings straight from the package's own test suite so you inherit its encoding decisions.
Laravel already generates this header. response()->download() and BinaryFileResponse go through Symfony's HeaderUtils::makeDisposition(), which implements RFC 6266/5987 including filename*. If you add this package only to generate headers, you're duplicating working framework code. Its real value is the parse direction and the explicit object model.
__toString() vs toString(). Small libraries in this space are inconsistent; some expose only an explicit method. If you pass the object straight into a header array and get Object of class ... could not be converted to string, add the explicit cast/call.
filename* wins over filename. Per RFC 6266, a receiver must prefer filename* when both are present. If you hand-roll parsing with explode(';') you will get this wrong — the legacy fallback is often ASCII-transliterated (Otchet 2024.pdf), so a naive parser silently mangles diacritics.
Don't explode(';', $header) yourself. Quoted parameter values can legally contain ; and escaped ". That's the whole point of using a parser. Same for the type: compare case-insensitively (Attachment is valid).
Percent-encoding is not urlencode. RFC 5987 uses %20 for spaces, never +. If you write your own encoder alongside this package the two will disagree on filenames containing spaces or +.
Sanitise before you serialise. A filename of "; filename="evil.exe or one containing a CRLF is a header-injection vector. Strip control characters and path separators (/, \, ..) before handing the name to the class — the library formats, it doesn't sanitise. Use basename() plus a regex strip for anything user-supplied.
Header size limits. Emitting both filename and filename* roughly doubles the header. Very long filenames can hit proxy/nginx buffer limits and get truncated; cap the filename length at ~150 chars before generating.
Low-maintenance, low-adoption package. 7 stars, 0 dependents, last release May 2023. That's not disqualifying for a pure-function class with no dependencies, but: pin the exact version in composer.json, and consider vendoring the single file into app/Support/ if you want to guarantee it never breaks under a PHP upgrade. There will be no security patches — though the attack surface here is genuinely tiny.
Unicode filenames need a browser test, not just a unit test. Chrome, Firefox and Safari differ in whether they honour filename* on inline dispositions, and Safari has historically mangled UTF-8 in inline; a green test suite does not mean the download lands with the right name on an iPhone.
Where to extend: since it's one class with no extension points, don't fight it. If you need policy (max length, transliteration table, blocked extensions), put that in a thin wrapper service in your app and keep the package as a pure formatter/parser. If you find yourself wanting to subclass it, you probably want your own 40-line implementation instead.
How can I help you explore Laravel packages today?