Streaming Media with PHP

Published Updated

PHP can enforce access control, keep media files outside the public web root, and make casual hotlinking harder. It cannot turn a playable full-length MP3 into something no user can capture. If a browser can play the whole file, the client has received the whole file or can record the output.

Think of PHP as the ticket desk at a venue. It checks whether the visitor may enter and chooses the right performance, while the sound system carries the audio. PHP should decide who may hear the file; the web server, framework response layer, CDN, or object storage layer should usually carry the bytes.

This is an advanced PHP tutorial because it crosses application code, HTTP headers, filesystem paths, media playback, and server delivery behavior. Read it after working with files in PHP and using sockets in PHP if you want the lower-level protocol boundaries fresh in your head.

What Should PHP Handle When Streaming Media?

PHP should check who may access each media file and select the authorised file before delivery starts:

  • Check the logged-in user, subscription, purchase, or preview entitlement.
  • Map a stable media ID to a server-owned filename.
  • Keep media files outside the public document root.
  • Choose whether the user gets a preview file or the full file.
  • Log the playback or download event if the product needs an audit trail.

That is application logic, and it belongs in PHP because it depends on sessions, accounts, orders, and database state.

The long transfer often belongs to a different layer. readfile() streams without loading the whole file into PHP memory, but worker concurrency, execution limits, output buffering, range support, and server configuration decide whether direct delivery is viable. Measure those constraints before choosing PHP delivery.

How Do You Protect Private Media Paths?

Protect private media by looking up a server-controlled path from an authorised media ID. Do not accept a filename from the query string:

<?php

$path = __DIR__ . "/../music/" . $_GET["file"];
readfile($path);

That code lets the request choose the filesystem path. The safer version lets the request choose an ID, then lets your application choose the real file:

<?php

$trackId = $_GET["track"] ?? "";
if (!is_string($trackId)) {
    http_response_code(400);
    exit;
}
$track = findTrackByPublicId($pdo, $trackId);

if ($track === null || !canStreamTrack($currentUser, $track)) {
    http_response_code(404);
    exit;
}

$base = realpath(__DIR__ . "/../private-audio");

if ($base === false) {
    http_response_code(500);
    exit;
}

$path = realpath($base . DIRECTORY_SEPARATOR . $track["storage_name"]);

if ($path === false || !str_starts_with($path, $base . DIRECTORY_SEPARATOR) || !is_file($path)) {
    http_response_code(404);
    exit;
}

The example assumes your application supplies $pdo, $currentUser, findTrackByPublicId(), and canStreamTrack(). Validate that track is a string before looking it up. The URL identifies a track record, and the database stores the server-owned filename. The final realpath() check prevents a bad record or a bad request from reaching outside the media directory.

If the path rules are still hazy, read working with files in PHP before building the media route.

How Can PHP Send a Small Media File?

PHP can send a small private media file with content headers and readfile() after checking access. This example sends the whole file and does not implement byte-range responses:

<?php

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($path);
$size = filesize($path);
$publicName = str_replace(["\"", "\r", "\n"], "", basename($track["public_name"]));

if ($mime === false || $size === false) {
    http_response_code(500);
    exit;
}

header("Content-Type: " . $mime);
header("Content-Length: " . $size);
header('Content-Disposition: inline; filename="' . $publicName . '"');
header("Cache-Control: private, no-store");

readfile($path);
exit;

readfile() reads a file and writes it to the output buffer. The PHP manual also notes that readfile() itself should not create a memory problem for large files, although output buffering can still change the result in a real deployment.

The more valuable the media and the larger the file, the more you should want PHP to authorize the response and then hand delivery to something built for delivery.

Why Do Media Players Need Range Requests?

Audio players often need byte ranges so a browser can seek, pause, resume, or request part of a file. MDN documents the Range header as the request header that asks a server to return only part of a resource. When the server accepts the request, it answers with 206 Partial Content.

That is why media serving is more subtle than a normal download. A route that always returns the whole file with 200 OK may appear to work until a user seeks into the middle of the track or a mobile browser resumes playback.

You can implement range handling in PHP, but it is easy to get wrong: invalid ranges, off-by-one Content-Range values, output buffering, and cache headers all have to agree. Let the web server or a framework component handle that work unless the application has a specific reason to own the protocol code.

Symfony's BinaryFileResponse, for example, handles Range and If-Range headers and supports X-Sendfile style handoff. That framework component shows how mature PHP tooling treats file delivery as response infrastructure.

How Do You Let nginx Send a Private Media File?

Let nginx send a private media file by configuring an internal location and returning an X-Accel-Redirect header from PHP after checking access. The browser requests /media/track/abc123, PHP authenticates the request and maps abc123 to a private file, and PHP returns a response that tells the web server or storage layer what to serve. The web server then handles the bytes, range requests, and transfer behavior.

On nginx, that often means an internal location plus an X-Accel-Redirect header:

<?php

header("Content-Type: audio/mpeg");
header('X-Accel-Redirect: /protected-audio/' . rawurlencode($track["storage_name"]));
exit;

The matching nginx location would be marked internal, so a browser cannot request /protected-audio/... directly. PHP grants the request, nginx serves the file, and the real path stays out of the public URL.

Apache deployments often use an X-Sendfile module for the same basic idea. Object storage can use signed URLs with short expiries. The product decision is the same in each case. PHP owns permission, and the delivery layer owns delivery.

How Do You Play Protected Audio in HTML?

Use an HTML audio element whose src points to the authorised PHP route:

<audio controls preload="metadata" src="/media/track/abc123">
    <a href="/media/track/abc123">Play audio</a>
</audio>

The preload="metadata" hint asks the browser to learn enough about the file without pulling the whole track immediately. The server still has to enforce access on every request. In this context, browser attributes are presentation hints rather than authorization.

For previews, consider using a separate preview file rather than trying to hide the full file behind JavaScript. A thirty-second sample and a paid full download are easier to reason about than a full-length file that the application pretends is only streamable.

Common Pitfalls & Debugging

Seeking Restarts or Fails

Dragging the audio position restarts playback or does nothing. The route always returns the complete file with 200 OK and does not honor byte ranges. Hand delivery to a range-aware web server, storage service, or tested framework response instead of improvising partial-content headers.

Headers Have Already Been Sent

Symptom: PHP warns that headers cannot be modified, or the media response contains stray text. Output was written before the status and media headers. Keep the delivery route free of template output and debugging text, set every header first, then transfer the file and exit.

Authorization Is Bypassed by a Public File

A signed-out visitor can open the storage URL directly. The file still lives under a public path even though an authorized PHP route also exists. Move the source outside the public document root or mark the server location internal so every request crosses the entitlement check.

Can PHP Prevent Users from Copying Media?

PHP access checks cannot stop an authorised listener from recording or saving playable media. They can stop unauthorised requests when every file delivery goes through the check. A complete private-media setup should also address:

  • Public URLs that expose the media directory.
  • Hotlinking to a static file path that bypasses PHP.
  • Serving paid files to users who no longer have access.
  • Query-string path traversal through a file parameter.
  • PHP workers being tied up for every large transfer.

Keep access checks and private storage in place, but do not promise that playable media cannot be copied.

Frequently Asked Questions

Does an audio or video element need crossorigin for same-origin media?

Same-origin audio and video do not need a crossorigin attribute. Cross-origin media may need CORS settings when a script reads its data, such as drawing video frames to a canvas.

Can a protected video player still use a poster image?

A video player using an authorised media route can still display a poster image. The poster loads from a separate URL, which can be an authorised image route or a public placeholder. The audio element has no poster attribute.

Does the browser download the whole file before playing?

A browser can start playing supported media before downloading the whole file. Startup and seeking depend on the file format, metadata location, connection and server response; byte-range support lets the player request specific portions.

Should a streaming route log every playback request?

A PHP media route should log only the playback events needed for its audit or operational purpose. Multiple range requests can belong to one playback, so do not count every request as a separate listen. Avoid recording tokens or unnecessary personal data.

Do HTTP range requests behave the same across browsers?

HTTP range responses follow a shared standard, but browsers can request different byte ranges and buffer media differently. Test seeking and resuming in the browsers you support. A server may return the full file with 200 OK when it ignores a range, but players that rely on partial content may then seek poorly.

Self-Check

  1. Multiple choice: Which layer should normally transfer a large authorized media file: PHP template rendering, a web server or storage layer, or browser JavaScript?
  2. Multiple choice: Which response status indicates a successful byte-range response: 200, 206, or 404?
  3. Predict the output: What status code and response body does the snippet after this list produce when $allowed is false?
  4. Multiple choice: Why does a duplicate public file bypass protection: it skips the entitlement check, ignores MIME detection, or disables range requests?
  5. Multiple choice: What does preload="metadata" control: the browser's pre-playback fetch hint, server authorization, or copy prevention?
<?php

$allowed = false;

if (! $allowed) {
    http_response_code(404);
    echo "Track not found";
    exit;
}

echo "Streaming";

Answers

  1. A web server or storage layer. PHP should authorize the request and hand off the sustained byte transfer.
  2. 206 Partial Content. It tells the client that the response contains the requested byte range.
  3. HTTP 404 with Track not found. The first branch sets the status, prints the message, and exits before Streaming can be written.
  4. The public path bypasses the entitlement check. Private media must have no second route that serves it without authorization.
  5. It hints how much the browser should fetch before playback. It does not change access control or make the received media impossible to save.

Next Steps

This page sits between filesystem handling and application security. The file logic belongs with working with files in PHP. The authorization logic belongs with PHP security fundamentals. If the media route is part of a JSON application, the request and status-code shape belongs with building your own API with PHP.

The lesson holds in both directions. Hide the direct file path and let PHP decide who gets access, then let the delivery layer carry the bytes once that decision has been made.

Sources

  1. [1]
    PHP readfile
    (php.net)
  2. [2]
    PHP fpassthru
    (php.net)
  3. [3]
    PHP header
    (php.net)
  4. [4]
  5. [5]
    Range header
    (developer.mozilla.org)
  6. [6]
    HTML audio element
    (developer.mozilla.org)
  7. [7]