Skip to main content

    Lesson 43 • Advanced

    File Storage Abstraction 💾

    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.

    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 upload form
    <!-- 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>
    This is the HTML half. Save it as a page, point its action at the PHP below, and you have a working upload.

    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.

    What $_FILES['photo'] contains
    <?php
    // upload.php — what the server receives. Each <input type="file" name="photo">
    // arrives as $_FILES["photo"], an array with five keys.
    
    $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)
    ?>
    Output
    name:     holiday.jpg
    type:     image/jpeg
    tmp_name: /tmp/phpA1b2C3
    error:    0
    size:     248173
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Validate: error, then size, then real MIME
    <?php
    // Trust NOTHING the browser sends. Validate in this order: error -> size -> MIME.
    $file = $_FILES["photo"] ?? null;
    
    // 1) Did the upload even arrive cleanly? error must be UPLOAD_ERR_OK (0).
    if ($file === null || $file["error"] !== UPLOAD_ERR_OK) {
        exit("Upload failed (error code " . ($file["error"] ?? "none") . ")\n");
    }
    
    // 2) Cap the size. Browsers can lie about type; size from PHP is real.
    $maxBytes = 2 * 1024 * 1024;            // 2 MB
    if ($file["size"] > $maxBytes) {
        exit("Too big: " . $file["size"] . " bytes (max " . $maxBytes . ")\n");
    }
    
    // 3) Detect the REAL MIME by reading the file's bytes — never $file["type"].
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime  = $finfo->file($file["tmp_name"]); // e.g. "image/jpeg", read from content
    $allowed = ["image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp"];
    
    if (!isset($allowed[$mime])) {
        exit("Blocked: detected type '$mime' is not an allowed image\n");
    }
    
    echo "Accepted a valid " . $mime . " of " . $file["size"] . " bytes.\n";
    ?>
    Output
    Accepted a valid image/jpeg of 248173 bytes.
    Adapt the tmp_name to a real file path to run this locally — the logic is what matters. finfo reads the file's actual bytes, so a renamed script is caught.

    Your turn. The check below is almost complete — fill in each ___ using the 👉 hint, then run it and compare with the Output panel.

    🎯 Your turn: finish the size + error check
    <?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
    ?>
    Output
    Too big
    Fill the two ___ blanks (the success constant, then the cap variable). With a 5 MB file and a 2 MB cap, it should print 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.

    Generate a safe name and move the file
    <?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";
    ?>
    Output
    Stored as: 9f8a4c1b7d2e6f30a5b8c9d0e1f2a3b4.jpg
    Full path: /var/www/storage/uploads/9f8a4c1b7d2e6f30a5b8c9d0e1f2a3b4.jpg
    The filename comes from random_bytes(), so it's different every run and impossible to guess. Run locally where /var/www/storage exists.

    Now build the safe filename yourself. Fill in the blanks so the name is 32 hex characters plus a dot and extension.

    🎯 Your turn: generate a safe filename
    <?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
    ?>
    Output
    Looks like: OK
    Use the function that returns secure random bytes and the variable holding the extension. A correct 32-char name plus .png is 36 characters, so it prints 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.

    A controller that gatekeeps downloads
    <?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
    ?>
    The regex /^[a-f0-9]{32}\.(jpg|png|webp)$/ only allows ids you generated, so ../../etc/passwd is rejected before any file is touched. Run on a real server to test.

    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.

    One API, any backend, no lock-in
    <?php
    // Flysystem gives every storage backend the SAME API, so swapping local disk
    // for S3 is a one-line config change, not a rewrite. No vendor lock-in.
    // Install: composer require league/flysystem
    require __DIR__ . "/vendor/autoload.php";
    
    use League\Flysystem\Filesystem;
    use League\Flysystem\Local\LocalFilesystemAdapter;
    use League\Flysystem\Visibility;
    
    // --- LOCAL adapter (development) ---
    $adapter    = new LocalFilesystemAdapter("/var/www/storage");
    $filesystem = new Filesystem($adapter);
    
    // --- The SAME code runs against S3 in production. Only the adapter changes:
    //   composer require league/flysystem-aws-s3-v3
    //   $adapter    = new AwsS3V3Adapter($s3Client, "myapp-uploads");
    //   $filesystem = new Filesystem($adapter);
    
    // write/read/copy/move/delete — identical on every backend.
    $filesystem->write("uploads/avatar.jpg", $jpgBytes, [
        "visibility" => Visibility::PUBLIC, // S3 objects default to PRIVATE
    ]);
    $contents = $filesystem->read("uploads/avatar.jpg");
    $filesystem->move("uploads/avatar.jpg", "archive/avatar.jpg");
    $filesystem->delete("uploads/old-photo.png");
    
    // STREAM big files so a 2 GB upload uses ~8 KB of RAM, not 2 GB:
    $stream = fopen("huge.zip", "rb");
    $filesystem->writeStream("backups/huge.zip", $stream);
    fclose($stream);
    
    echo "Storage backend swapped with one line — app code untouched.\n";
    ?>
    This needs Composer (composer require league/flysystem) and a configured backend, so it won't run in an online sandbox — run it on your own server.

    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

    ToolExampleWhat 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…

    Frequently Asked Questions

    Q: 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.

    Q: 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'.

    Q: 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.

    Q: 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 '..'.

    Q: 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.

    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.

    🎯 Mini-Challenge: write validateUpload()
    <?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
    ?>
    Return "OK" only when the error, size, and MIME all pass; otherwise return the matching reason string. Three test calls should print OK, too big, bad type.

    🎉 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
    • Next lesson: Performance Optimization — tune OPcache and make PHP fly

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service