Streaming Media with PHP
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 PHP Should Own
PHP is useful at the authorization boundary:
- 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.
Keep Paths Server-owned
The dangerous version takes 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"] ?? "";
$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;
} That shape is more important than the streaming function. 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.
A Small PHP Response
For a modest private file, PHP can send the response directly:
<?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.
That does not make this the best design for every media file. It only means the primitive exists in the language. 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.
Range Requests Matter for Media
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.
Hand off the Heavy Transfer
The cleaner production shape is a handoff. 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.
The ticket-desk boundary stays intact during this handoff. PHP approves the visit, then the delivery system does the sustained work it was built to handle.
Use the Audio Element Plainly
The browser side does not need special ceremony:
<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
Symptom: dragging the audio position restarts playback or does nothing. Cause: the route always returns the complete file with 200 OK and does not honor byte ranges. Fix: 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. Cause: output was written before the status and media headers. Fix: 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
Symptom: a signed-out visitor can open the storage URL directly. Cause: the file still lives under a public path even though an authorized PHP route also exists. Fix: move the source outside the public document root or mark the server location internal so every request crosses the entitlement check.
Limits and Guarantees
This design can prevent several ordinary mistakes:
- 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.
It does not prevent a determined user from recording or saving media they can play. Real control is access, entitlement, logging, previews, watermarking, and legal terms. A snippet that promises playback without copying is offering an illusion.
Frequently Asked Questions
Does an audio or video element need crossorigin for same-origin media?
No. That attribute matters when media comes from another origin and a script needs to read the data, such as drawing frames to a canvas. A same-origin route needs nothing extra.
Can an authorised media route still use a poster image?
Yes. The poster is just another URL and loads independently of the stream, so it can point at a separate authorised route or a public placeholder without weakening the check on the media itself.
Does the browser download the whole file before playing?
No. It requests the first part, begins playback, and asks for more as needed, which is why range support matters. A player can start within a second on a file that would take minutes to fetch whole.
Should a streaming route log every playback request?
Log selectively: the track, the user, and the time are enough for an audit trail. Avoid full request bodies or query-string tokens, and remember a playback log is a record of what a named person listened to.
Do HTTP range requests behave the same across browsers?
Range requests are part of the HTTP standard rather than a browser feature, so a correctly implemented endpoint serves every modern browser the same way. Problems come from the server side: returning the whole file with a 200 instead of honouring the Range header breaks seeking in any player that depends on partial content.
Self-Check
- Multiple choice: Which layer should normally transfer a large authorized media file: PHP template rendering, a web server or storage layer, or browser JavaScript?
- Multiple choice: Which response status indicates a successful byte-range response:
200,206, or404? - Predict the output: What status code and response body does the snippet after this list produce when
$allowedisfalse? - Multiple choice: Why does a duplicate public file bypass protection: it skips the entitlement check, ignores MIME detection, or disables range requests?
- 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
- A web server or storage layer. PHP should authorize the request and hand off the sustained byte transfer.
206 Partial Content. It tells the client that the response contains the requested byte range.- HTTP 404 with
Track not found. The first branch sets the status, prints the message, and exits beforeStreamingcan be written. - The public path bypasses the entitlement check. Private media must have no second route that serves it without authorization.
- 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.
This example has been updated from the original CodeWalkers MP3 streaming article, which promised playback in the browser without downloading. Access control and a server-owned path still work; the anti-download promise never did, which is why this page separates entitlement from delivery.
Sources
-
[1]
PHP readfile(php.net)
-
[2]
PHP fpassthru(php.net)
-
[3]
PHP header(php.net)
-
[4]
PHP finfo_file(php.net)
-
[5]
Range header(developer.mozilla.org)
-
[6]
HTML audio element(developer.mozilla.org)
-
[7]
Symfony HttpFoundation Component(symfony.com)
Read Next
A security reference for PHP login flows: password hashing, session ID rotation, secure cookies, CSRF tokens, and where CAPTCHA actually helps.
Read, write, append, lock, and stream text files in PHP without copying old unsafe file-handling habits.
Modern PHP reference guides for frameworks, PDO, configuration, pagination, conditionals, forms, files, sockets, media streaming, mini chat apps, XML, templates, OOP, APIs, email, SOAP, recursion, SQL-backed apps, and security fundamentals.