File Storage Abstraction
Reviewed & published by Brayan K
By the end of this lesson you'll handle file uploads the safe way — validate them, store them where they can't bite you, serve them through a controller, and abstract storage so the same code runs on local disk or Amazon S3.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Read an upload from $_FILES and understand each of its five keys
- Validate every upload by error, real size, and true MIME type
- Move a file safely with move_uploaded_file and an unguessable name
- Store uploads outside the web root and serve them through PHP
- Stop path traversal and MIME-spoofing attacks before they start
- Abstract storage with Flysystem to swap local disk for S3 in one line
1️⃣ The Upload Form
A file upload starts in the browser with an HTML form. Two attributes are mandatory: method="post" (files are far too big for a URL) and enctype="multipart/form-data" (this tells the browser to send the raw file bytes, not just text). The name on the file input — here photo — becomes the key your PHP reads on the server.
<!-- The HTML form. Two non-negotiable attributes for file uploads: -->
<!-- method="post" files are too big for the URL -->
<!-- enctype="multipart/form-data" tells the browser to send raw bytes -->
<form action="upload.php" method="post" enctype="multipart/form-data">
<!-- name="photo" becomes the key in $_FILES on the server. -->
<input type="file" name="photo" accept="image/*">
<button type="submit">Upload</button>
</form>2️⃣ Reading $_FILES
When the form submits, PHP collects the upload into the $_FILES superglobal — a built-in array PHP fills in for you. Each file input gives you an array with five keys. The crucial thing to learn here: only two of them come from PHP and can be trusted (tmp_name, size, error). The name and type come straight from the browser and a user can fake them.
<?php
// upload.php — what the server receives. Each <input type="file" name="photo">
// arrives as $_FILES["photo"], an array with five keys.
// No browser here, so stand in for one: write a real 1x1 JPEG and describe it
// exactly as PHP would after an upload. On a real request PHP fills $_FILES
// itself and tmp_name points inside the system temp directory.
$demo = imagecreatetruecolor(40, 30);
imagejpeg($demo, "demo-upload.jpg");
imagedestroy($demo);
$_FILES = ["photo" => [
"name" => "holiday.jpg",
"type" => "image/jpeg",
"tmp_name" => "demo-upload.jpg",
"error" => 0,
"size" => filesize("demo-upload.jpg"),
]];
$file = $_FILES["photo"];
echo "name: " . $file["name"] . "\n"; // original filename FROM THE USER (untrusted!)
echo "type: " . $file["type"] . "\n"; // MIME the BROWSER claims (untrusted!)
echo "tmp_name: " . $file["tmp_name"] . "\n"; // where PHP parked it on disk, temporarily
echo "error: " . $file["error"] . "\n"; // 0 means success (see UPLOAD_ERR_* constants)
echo "size: " . $file["size"] . "\n"; // size in bytes, measured by PHP (trusted)
?>tmp_name is a temporary file PHP created in /tmp; it is deleted automatically when the script ends, so you must move it somewhere permanent if you want to keep it. An error of 0 (the constant UPLOAD_ERR_OK) means the upload arrived cleanly.
3️⃣ Validate Everything
This is the most important section in the lesson. Never trust a single thing the browser tells you. Validate in three steps: confirm the upload succeeded, cap the real size (which PHP measured), and detect the true file type by reading the bytes with the finfo class — never $file["type"]. A whitelist of allowed types is far safer than a blacklist, because you can never list every dangerous extension.
Your turn. The check below is almost complete — fill in each ___ using the 👉 hint, then run it and compare with the Output panel.
<?php
// 🎯 YOUR TURN — finish the size + error check, then run it.
// $file is a pretend $_FILES entry so this runs anywhere.
$file = ["error" => 0, "size" => 5_000_000, "tmp_name" => "/tmp/x"];
$maxBytes = 2 * 1024 * 1024; // 2 MB
// 1) Reject the upload unless error is exactly UPLOAD_ERR_OK
if ($file["error"] !== ___) { // 👉 the constant that means "success"
exit("Upload failed\n");
}
// 2) Reject anything bigger than $maxBytes
if ($file["size"] > ___) { // 👉 the variable holding the 2 MB cap
exit("Too big\n");
}
echo "Size check passed\n";
// ✅ Expected output (with size 5,000,000 and a 2 MB cap):
// Too big
?>4️⃣ Store It Safely
A validated file is still sitting in /tmp, about to be deleted. Move it with move_uploaded_file() — the only mover that first verifies the file truly came from an HTTP upload (rename() and copy() don't, and that's exploitable). Two rules make storage safe: generate your own unguessable filename (never reuse the user's, which can contain ../ or collide), and store outside the web root so the file can't be requested or executed by URL.
<?php
// After validation, MOVE the file out of /tmp into permanent, SAFE storage.
$file = $_FILES["photo"];
$ext = "jpg"; // from your validated $allowed map, NOT the user
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file["tmp_name"]);
// Generate a SAFE, unguessable filename. Never reuse the user's name —
// it can contain "../", null bytes, or collide with another upload.
$safeName = bin2hex(random_bytes(16)) . "." . $ext; // e.g. 9f8a...e2.jpg
// Store OUTSIDE the web root so the file can't be requested or executed directly.
$storageDir = "/var/www/storage/uploads"; // NOT inside /public
$dest = $storageDir . "/" . $safeName;
// move_uploaded_file() is the ONLY safe mover: it confirms the file really came
// from an HTTP upload before moving it. Plain rename()/copy() do not.
if (!move_uploaded_file($file["tmp_name"], $dest)) {
exit("Could not store the file.\n");
}
echo "Stored as: $safeName\n";
echo "Full path: $dest\n";
?>Now build the safe filename yourself. Fill in the blanks so the name is 32 hex characters plus a dot and extension.
<?php
// 🎯 YOUR TURN — generate a SAFE filename, then run it.
// Never trust the user's original name. Build your own from random bytes
// plus an extension you chose from a whitelist.
$ext = "png"; // came from your validated MIME map, NOT from the user
// 1) 16 random bytes as hex => a 32-char, unguessable, collision-proof name
$random = bin2hex(___); // 👉 the function that returns N secure random bytes
// 2) Join the random part and the extension with a dot
$safeName = $random . "." . ___; // 👉 the variable holding the extension
echo "Looks like: " . (strlen($safeName) === 36 ? "OK" : "WRONG LENGTH") . "\n";
// ✅ Expected output:
// Looks like: OK
?>5️⃣ Serving Files Through PHP
Because your files live outside the web root, no one can link to them directly — which is exactly what you want. To let the right people see them, write a small controller: a PHP script that takes a file id, whitelists the characters it accepts (this single regex defeats path traversal), checks the visitor is allowed, then streams the bytes. Setting the Content-Type from the detected MIME stops the browser guessing.
<?php
// serve.php?id=9f8a...b4 — files live OUTSIDE the web root, so a PHP controller
// is the gatekeeper. It can check login/ownership BEFORE streaming the bytes.
$id = $_GET["id"] ?? "";
// Allow only the safe charset you generated. This kills path traversal: a value
// like "../../etc/passwd" contains "/" and "." and is rejected outright.
if (!preg_match("/^[a-f0-9]{32}\.(jpg|png|webp)$/", $id)) {
http_response_code(400);
exit("Bad file id\n");
}
$path = "/var/www/storage/uploads/" . $id; // safe: $id is already whitelisted
if (!is_file($path)) {
http_response_code(404);
exit("Not found\n");
}
// (Here you would check the logged-in user is allowed to see THIS file.)
$finfo = new finfo(FILEINFO_MIME_TYPE);
header("Content-Type: " . $finfo->file($path));
header("Content-Length: " . filesize($path));
readfile($path); // streams the file straight to the browser
?>6️⃣ Abstracting Storage (Local vs S3)
Local disk is fine for one server, but the moment you run two servers — or deploy to hosting that wipes the disk — uploads need to live in cloud object storage like Amazon S3. The trick is to never hard-code which one. The Flysystem library gives every backend the same methods (write, read, delete), so you write your app once and switch from local to S3 by changing a single line — the adapter. That's an abstraction: one stable interface hiding many different implementations.
Common Errors (and the fix)
- Trusting the browser's MIME type — checking $file["type"] lets an attacker upload a script renamed to photo.jpg that still claims image/jpeg. Detect the real type from the bytes with new finfo(FILEINFO_MIME_TYPE) and a whitelist.
- Path traversal — building a path from the user's filename or id lets ../../etc/passwd escape your folder. Generate your own names with bin2hex(random_bytes(16)) and only accept ids matching a strict pattern.
- Storing uploads in the web root — a file in public/ can be requested (and possibly executed) by URL. Store outside the web root (e.g. /var/www/storage) and serve through a PHP controller.
- No size limit — without a cap, one huge upload fills your disk or exhausts memory. Check $file["size"] in code and set upload_max_filesize / post_max_size in php.ini.
- "The file ... was not uploaded via HTTP POST" — you used rename() or copy() on a temp upload. Always move it with move_uploaded_file().
Pro Tips
- 💡 Derive the extension from the detected MIME, not from the user's filename — that way the name on disk can never lie about what the file is.
- 💡 In tests, use Flysystem's InMemory adapter. It's instant and needs no real filesystem, so upload tests stay fast and clean.
- 💡 Stream large files with writeStream()/readStream() — a 2 GB file then uses ~8 KB of RAM instead of 2 GB.
📋 Quick Reference — File Uploads & Storage
| Tool | Example | What It Does |
|---|---|---|
| $_FILES | $_FILES["photo"] | Holds the uploaded file's data |
| finfo | $f->file($tmp) | Detect the REAL MIME from bytes |
| random_bytes() | bin2hex(random_bytes(16)) | Make a safe, unguessable name |
| move_uploaded_file() | move_uploaded_file($tmp,$dst) | The only safe way to store an upload |
| readfile() | readfile($path) | Stream a file to the browser |
| Flysystem | $fs->write($path,$bytes) | One API for local, S3, GCS… |
Mini-Challenge: Validate an Upload
No code is filled in this time — just a brief and an outline. Write the function yourself, run it on onecompiler.com/php with the test data, then check your result against the expected output in the comments. This write-run-check loop is exactly what you'll do on every real upload handler.
<?php
// 🎯 MINI-CHALLENGE: a tiny "validate an upload" function.
// No code is filled in — work from the steps, then run it with the test data.
//
// Write validateUpload(array $file): string that returns "OK" or a reason:
// 1. If $file["error"] is not UPLOAD_ERR_OK -> return "bad upload"
// 2. If $file["size"] is greater than 1 MB (1048576) -> return "too big"
// 3. If $file["mime"] is not in ["image/jpeg","image/png"] -> return "bad type"
// 4. Otherwise -> return "OK"
//
// Then test it:
// echo validateUpload(["error"=>0,"size"=>500,"mime"=>"image/png"]) . "\n"; // OK
// echo validateUpload(["error"=>0,"size"=>9_000_000,"mime"=>"image/png"]) . "\n"; // too big
// echo validateUpload(["error"=>0,"size"=>10,"mime"=>"text/html"]) . "\n"; // bad type
//
// ✅ Expected output:
// OK
// too big
// bad type
// your code here
?>🎉 Lesson Complete!
- ✅ Uploads arrive in $_FILES; only tmp_name, size, and error can be trusted
- ✅ Validate by error → size → real MIME (via finfo), always with a whitelist
- ✅ Store with move_uploaded_file(), a random safe name, and a folder outside the web root
- ✅ Serve files through a PHP controller that whitelists ids to defeat path traversal
- ✅ Abstract storage with Flysystem — swap local disk for S3 in one line, no lock-in
Practice quiz
Which keys of $_FILES['photo'] come from PHP and can be trusted?
- name and type
- type and size only
- tmp_name, size, and error
- name, type, and tmp_name
Answer: tmp_name, size, and error. tmp_name, size, and error are set by PHP; name and type come straight from the browser and a user can fake them.
Why must you NOT trust $_FILES['photo']['type'] to validate a file's type?
- It is sent by the browser and can be faked, so a script renamed to dog.jpg can claim image/jpeg
- It is always empty
- PHP encrypts it
- It only works for images
Answer: It is sent by the browser and can be faked, so a script renamed to dog.jpg can claim image/jpeg. The browser-supplied type is untrusted; detect the real type from the file's bytes with the finfo class.
How do you detect the REAL MIME type of an uploaded file?
- Read the file extension from the name
- Trust $_FILES['type']
- Check the Content-Length header
- Use new finfo(FILEINFO_MIME_TYPE) then ->file($tmpName) to read the actual bytes
Answer: Use new finfo(FILEINFO_MIME_TYPE) then ->file($tmpName) to read the actual bytes. finfo reads the file's content, so a renamed script is caught regardless of its claimed type or extension.
What is the correct order to validate an upload?
- MIME, then size, then error
- error, then size, then real MIME
- name, then type, then size
- size, then name, then error
Answer: error, then size, then real MIME. First confirm error is UPLOAD_ERR_OK, then cap the size, then detect the true MIME with a whitelist.
Which function is the ONLY safe way to move an uploaded file out of /tmp?
- move_uploaded_file()
- rename()
- copy()
- file_put_contents()
Answer: move_uploaded_file(). move_uploaded_file() first confirms the file genuinely came from an HTTP upload; rename() and copy() do not and are exploitable.
How should you generate the stored filename for an upload?
- Reuse the user's original filename
- Use the current timestamp only
- Generate your own unguessable name, e.g. bin2hex(random_bytes(16)) plus a whitelisted extension
- Use the file's MIME type as the name
Answer: Generate your own unguessable name, e.g. bin2hex(random_bytes(16)) plus a whitelisted extension. Never reuse the user's name (it can contain ../ or collide). Build a safe, random, collision-proof name instead.
Why should uploads be stored OUTSIDE the web root (e.g. /var/www/storage, not /public)?
- To save disk space
- So files can't be requested or executed directly by URL — only a PHP controller you wrote can serve them
- Because PHP can't read files inside /public
- To make uploads load faster
Answer: So files can't be requested or executed directly by URL — only a PHP controller you wrote can serve them. Outside the web root, the only way to reach a file is through a controller that can check login and ownership first.
What is path traversal, and how does the serve controller defend against it?
- Sending too many requests; the controller rate-limits them
- Uploading a file that is too large; the controller checks the size
- Guessing a password; the controller hashes it
- Using '../' in an id to escape the storage folder; the controller whitelists the id with a strict regex
Answer: Using '../' in an id to escape the storage folder; the controller whitelists the id with a strict regex. A strict pattern like /^[a-f0-9]{32}\.(jpg|png|webp)$/ rejects any value containing '/' or '..' before a file is touched.
What does the Flysystem library give you?
- Automatic image resizing
- One identical API (write/read/delete) across backends, so you swap local disk for S3 with a one-line adapter change
- Built-in virus scanning of uploads
- Faster database queries
Answer: One identical API (write/read/delete) across backends, so you swap local disk for S3 with a one-line adapter change. Flysystem abstracts storage: the same app code runs against local disk or S3 by changing only the adapter — no lock-in.
What does an error value of UPLOAD_ERR_OK (0) on $_FILES indicate?
- The file is an image
- The file was rejected
- The upload arrived cleanly with no error
- The file exceeds the size limit
Answer: The upload arrived cleanly with no error. error === UPLOAD_ERR_OK (0) means the upload completed successfully; any other value signals a failure.
Continue this course
- Previous: Building a Role & Permission System (RBAC, ACL)
- Next: Optimizing PHP Performance & Understanding OPcache — Profile PHP apps, tune OPcache, and eliminate slow code paths
- Quick reference: PHP cheat sheet
Frequently asked questions
Why can't I just trust $_FILES['photo']['type'] for the file type?
Because that value is sent by the browser and can be anything an attacker wants — it is never verified by PHP. A malicious script renamed to dog.jpg can arrive claiming type image/jpeg. Always detect the real type from the file's bytes with the finfo class (new finfo(FILEINFO_MIME_TYPE) then ->file($tmpName)), which reads the actual content. Treat $_FILES['type'] and $_FILES['name'] as untrusted user input.
Why store uploads outside the web root instead of in /public?
Anything inside the web root is reachable by a direct URL, and if the server is misconfigured an uploaded .php file could be executed, giving an attacker control of your server. Storing uploads in a folder like /var/www/storage (outside /public) means the only way to reach a file is through a PHP controller you wrote — which can check login, ownership, and permissions before streaming the bytes. It turns 'anyone with the URL' into 'only people you allow'.
What's the difference between move_uploaded_file() and rename() or copy()?
move_uploaded_file() first confirms the file genuinely came from an HTTP POST upload (it was listed in $_FILES and lives in PHP's temp area) before moving it. rename() and copy() do no such check, so a crafted request could trick them into moving a sensitive server file like /etc/passwd into your uploads folder. For handling uploads, always use move_uploaded_file() — it is the only mover that is safe against this class of attack.
What is path traversal and how do I stop it?
Path traversal is when an attacker puts '../' sequences in a filename or id to escape your storage folder and read or write files elsewhere — for example a value of ../../etc/passwd. You stop it two ways: never build a path from a raw user-supplied name, and whitelist the characters you accept. If you generate your own filenames with bin2hex(random_bytes(16)) and only ever accept ids matching a strict pattern like /^[a-f0-9]{32}\.(jpg|png|webp)$/, there is no way to inject '/' or '..'.
When should I move from local storage to S3 (or another cloud)?
Local disk is fine for a single server, but it breaks the moment you scale to multiple servers (an upload on server A isn't on server B) or use ephemeral hosting where the disk is wiped on each deploy. Cloud object storage like S3 solves both. Use the Flysystem library so your application code calls write()/read()/delete() against one interface and you switch from the Local adapter to the S3 adapter with a one-line config change — no rewrite, and no vendor lock-in.