Web Development

Content-Disposition in PHP - A Download Filename Is Metadata, Not a Header Fragment

Content-Disposition in PHP - A Download Filename Is Metadata, Not a Header Fragment

A PHP endpoint has selected the correct PDF, checked that the current user may download it, and is ready to send the response. One small question remains: what should the saved file be called? It is tempting to place a database title directly after filename=. That shortcut mixes application text with HTTP syntax, and it becomes especially fragile when the name contains spaces, quotation marks, non-ASCII characters, or line breaks.

The safer mental model is simple: a download filename is metadata. It is not a filesystem path, not proof of the payload's media type, and not a fragment of a header that input may extend. This article looks narrowly at generating the Content-Disposition response field in PHP. Authorization, path resolution, byte ranges, caching, and efficient transfer remain separate responsibilities.

One name crosses several boundaries

A useful download endpoint may have at least three different names in play:

  • An internal storage name, perhaps an opaque identifier such as 8f/42/object.bin.
  • A display name stored by the application, such as Laporan Oktober 2026.pdf.
  • A local filename ultimately chosen or adjusted by the browser and operating system.

Those names do not need to be identical. Keeping them separate prevents a display preference from becoming a storage lookup. The application can authorize an object by its internal identifier, select a media type from trusted metadata, and only then offer a human-readable name.

RFC 6266 defines Content-Disposition as additional information about how a response payload should be processed. It can also carry a suggested filename for saving that payload. The word suggested matters: the recipient must treat this filename as advisory rather than as authority over a local filesystem.

Attachment is guidance, not remote control

The two familiar disposition types are inline and attachment. According to RFC 6266, inline implies normal processing, while attachment indicates that the recipient should prompt the user to save the response locally. An unknown type should be handled like attachment.

Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"

This does not mean the server controls every user interface decision. A browser may apply its own security policy, user preference, or platform convention. Nor does attachment correct an inaccurate Content-Type. The disposition describes intended handling; the media type describes the representation. A reliable endpoint sends both deliberately.

This response field is also different from the Content-Disposition fields inside multipart/form-data. RFC 6266 explicitly excludes those payload-body fields from its scope. Reusing examples between upload parsing and download responses without checking the applicable specification can therefore produce the wrong assumptions.

Why there are two filename parameters

The conventional filename parameter works well for a conservative ASCII fallback. A quoted string is needed when the value contains spaces. Simply inserting UTF-8 bytes into that quoted string is not the interoperable internationalization mechanism defined by the standard.

The extended filename* parameter carries a character encoding, optional language, and encoded value. RFC 6266 originally referred to RFC 5987 for this format; RFC 8187 later obsoleted RFC 5987 and requires producers to use UTF-8. A filename such as laporan-äkhir.pdf can be represented as:

Content-Disposition: attachment;
 filename="laporan-akhir.pdf";
 filename*=UTF-8''laporan-%C3%A4khir.pdf

The two apostrophes after UTF-8 leave the optional language component empty. The extended value is not enclosed in quotation marks. Bytes that are not allowed directly are percent-encoded, and a space becomes %20, not +.

When both parameters appear, RFC 6266 says recipients should prefer filename* and ignore filename. The ASCII value remains a compatibility fallback. The RFC's generation advice places that fallback first because some older implementations had parsing problems when the order was reversed. This pairing avoids guessing a user agent from its name.

Validate before encoding

Encoding is not validation. Percent-encoding can serialize Unicode, but it does not decide whether a name is appropriate. RFC 8187 also notes that Unicode can enable visual spoofing: two names can look deceptively similar while containing different characters.

An application should define a filename policy before it reaches the header API. A modest policy for generated downloads might include these decisions:

  • Accept only valid UTF-8 and a bounded number of bytes.
  • Reject control characters, including carriage return and line feed.
  • Reject both / and \ so the display name cannot resemble a path.
  • Choose an extension that agrees with the trusted payload type instead of preserving an arbitrary supplied extension.
  • Produce a non-empty ASCII fallback, even when the original name has no ASCII letters.

The exact length bound is an application decision. RFC 9110 does not define one universal field-length limit; servers, proxies, frameworks, and clients have practical limits. A bound should therefore be documented and tested across the actual deployment rather than presented as an HTTP constant.

A deliberately scoped PHP helper

The following helper accepts an already authorized, application-selected display name. Its 180-byte limit is a local policy for this example, not a requirement from an RFC. It rejects path separators and controls, builds a plain ASCII fallback, and serializes the original UTF-8 name as filename*.

<?php
declare(strict_types=1);

function attachmentDisposition(string $displayName): string
{
    if ($displayName === '' || strlen($displayName) > 180) {
        throw new InvalidArgumentException('Invalid filename length');
    }

    if (preg_match('//u', $displayName) !== 1) {
        throw new InvalidArgumentException('Filename must be valid UTF-8');
    }

    if (
        preg_match('/[\x00-\x1F\x7F]/u', $displayName) === 1
        || str_contains($displayName, '/')
        || str_contains($displayName, '\\')
    ) {
        throw new InvalidArgumentException('Filename contains a forbidden character');
    }

    $fallback = preg_replace('/[^A-Za-z0-9._ -]/', '_', $displayName);
    $fallback = trim((string) $fallback, " .");

    if ($fallback === '' || $fallback === '..') {
        $fallback = 'download';
    }

    $fallback = addcslashes($fallback, "\\\"");

    return 'attachment; filename="' . $fallback . '"; filename*=UTF-8\'\''
        . rawurlencode($displayName);
}

$downloadName = 'Laporan Oktober 2026.pdf';

header('Content-Type: application/pdf');
header('Content-Disposition: ' . attachmentDisposition($downloadName));
// Transfer the already authorized PDF here.

rawurlencode() is useful here because its output is a valid conservative subset of the characters allowed in an RFC 8187 extended value: spaces become %20, and UTF-8 octets are percent-encoded. The fallback replacement is intentionally plain rather than linguistically clever. Transliteration is locale-dependent; a generic but honest fallback is safer than pretending one mapping is correct for every language.

The final call uses PHP's raw header API. The PHP manual states that header() must run before actual output. A byte-order mark, whitespace outside PHP tags, or earlier diagnostic output can therefore break the response before filename handling is even considered.

What this helper does not solve

A compact helper can make one boundary clearer, but it should not collect unrelated trust decisions.

First, it does not turn a name into a path. The endpoint should resolve an authorized object independently; concatenating this display name onto a storage directory would undo that separation. Second, it does not inspect the payload or select a safe media type. If the application allows users to choose a misleading executable extension for arbitrary bytes, syntactically valid headers do not make the download benign.

Third, it does not guarantee the final local name. RFC 6266 instructs recipients to discard path information, consider extension safety, replace confusing characters, and handle special names such as . and ... Browsers and operating systems may add further rules. Sender-side validation reduces ambiguity; it cannot replace recipient-side safety.

Finally, this is not a complete download controller. Authentication, authorization, cache policy, Content-Length, conditional requests, range handling, logging, and transfer errors require their own design. For large or private files, delegating transfer to a capable web server after the application authorizes the request may be preferable to streaming every byte through PHP.

Test the serialized response

A unit test should assert the exact field value for ASCII and non-ASCII examples, then reject line breaks, separators, malformed UTF-8, empty values, and names beyond the chosen limit. An integration test should inspect the response that leaves the deployed stack:

curl -sS -D - -o /dev/null \
  'https://example.test/download/42'

Check for one valid Content-Disposition field, the intended disposition type, the ASCII fallback, the UTF-8 extended value, and a Content-Type that matches the representation. Test through the same reverse proxy used in production. A browser test can then confirm the user-facing behavior, but it should not replace protocol-level assertions: a browser may quietly recover from malformed syntax that another client rejects.

Conclusion

The difficult part of a download filename is not calling header(). It is preserving the boundary between application text and protocol syntax. Select and authorize the object independently, validate a bounded UTF-8 display name, offer a conservative ASCII filename, carry the real internationalized name in filename*, and verify the serialized response.

Even then, the filename remains advice. That limitation is not a flaw to work around; it is what allows the recipient to protect its own filesystem and respect the user's choice. A good download endpoint makes a precise suggestion without pretending it controls the other side of the connection.

References