Skip to content

PHP Tutorial Practical PHP Projects

Project 5: File Upload Manager

Lesson 351 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

Level: intermediate. You will build a small file manager: users upload files, see a list, download them and delete them. The focus is security. File handling is where careless code gets servers hacked.

The rules (from the upload lessons)

  1. Allow only specific types, checked by content (finfo), not by name.
  2. Limit the size.
  3. Store files under a random name, outside the web root.
  4. Keep the original name only as data in the database, and escape it when you show it.
  5. Serve downloads through PHP, with safe headers.
  6. Delete with POST and a CSRF token.

The table

query.sql
SQL
CREATE TABLE uploads (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    original_name VARCHAR(255) NOT NULL,
    stored_name CHAR(36) NOT NULL UNIQUE,
    mime VARCHAR(100) NOT NULL,
    size INT UNSIGNED NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The upload manager

UploadManager.php
PHP
<?php

declare(strict_types=1);

final class UploadException extends RuntimeException {}

final class UploadManager
{
    /** allowed MIME type => the extension WE use */
    private const ALLOWED = [
        "image/png" => "png",
        "image/jpeg" => "jpg",
        "image/webp" => "webp",
        "application/pdf" => "pdf",
    ];

    /**
     * @param callable(string, string): bool $mover moves the temp file; move_uploaded_file in real use
     */
    public function __construct(
        private PDO $pdo,
        private string $folder,
        private int $maxBytes = 2_000_000,
        private $mover = "move_uploaded_file",
    ) {}

    /** @param array{name?: string, tmp_name?: string, error?: int, size?: int} $file one entry of $_FILES */
    public function handle(array $file): int
    {
        $error = $file["error"] ?? UPLOAD_ERR_NO_FILE;

        if ($error !== UPLOAD_ERR_OK) {
            throw new UploadException(match ($error) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => "The file is too large.",
                UPLOAD_ERR_NO_FILE => "Please choose a file.",
                default => "The upload failed. Please try again.",
            });
        }

        $tmp = (string) ($file["tmp_name"] ?? "");
        $size = (int) ($file["size"] ?? 0);

        if ($size < 1 || $size > $this->maxBytes) {
            throw new UploadException("The file must be between 1 byte and " . number_format($this->maxBytes / 1_000_000, 1) . " MB.");
        }

        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);

        if (!is_string($mime) || !isset(self::ALLOWED[$mime])) {
            throw new UploadException("Only PNG, JPEG, WebP and PDF files are allowed.");
        }

        if (str_starts_with($mime, "image/") && getimagesize($tmp) === false) {
            throw new UploadException("The image is not valid.");
        }

        if (!is_dir($this->folder)) {
            mkdir($this->folder, 0755, true);
        }

        $stored = bin2hex(random_bytes(16)) . "." . self::ALLOWED[$mime];
        $target = $this->folder . "/" . $stored;

        if (!($this->mover)($tmp, $target)) {
            throw new UploadException("The file could not be saved.");
        }

        $stmt = $this->pdo->prepare(
            "INSERT INTO uploads (original_name, stored_name, mime, size, created_at) VALUES (:name, :stored, :mime, :size, :created)"
        );
        $stmt->execute([
            "name" => $this->cleanName((string) ($file["name"] ?? "file")),
            "stored" => $stored,
            "mime" => $mime,
            "size" => $size,
            "created" => date("Y-m-d H:i:s"),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    public function all(): array
    {
        return $this->pdo->query("SELECT id, original_name, mime, size FROM uploads ORDER BY id DESC")->fetchAll();
    }

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare("SELECT id, original_name, stored_name, mime, size FROM uploads WHERE id = :id");
        $stmt->execute(["id" => $id]);

        return $stmt->fetch() ?: null;
    }

    public function path(array $row): string
    {
        return $this->folder . "/" . basename($row["stored_name"]);
    }

    public function delete(int $id): bool
    {
        $row = $this->find($id);

        if ($row === null) {
            return false;
        }

        $this->pdo->prepare("DELETE FROM uploads WHERE id = :id")->execute(["id" => $id]);

        $path = $this->path($row);

        if (is_file($path)) {
            unlink($path);
        }

        return true;
    }

    private function cleanName(string $name): string
    {
        $name = basename(str_replace("\\", "/", $name));
        $name = preg_replace('/[^\p{L}\p{N}._ -]+/u', "_", $name) ?? "file";

        return mb_substr(trim($name), 0, 150) ?: "file";
    }
}

Notice $mover. In production it is move_uploaded_file, which works only for real HTTP uploads. In a test we pass copy, so we can try the whole class from the command line.

Test it with real files

index.php
PHP
<?php
require __DIR__ . "/UploadManager.php";

$pdo = require __DIR__ . "/db.php";
$manager = new UploadManager($pdo, __DIR__ . "/storage/uploads", 50_000, "copy");

$png = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
file_put_contents("photo.tmp", $png);
file_put_contents("evil.tmp", "<?php echo 'gotcha';");
file_put_contents("big.tmp", str_repeat("x", 60000));

$attempts = [
    "a real image" => ["name" => "../../holiday photo.png", "tmp_name" => "photo.tmp", "error" => 0, "size" => strlen($png)],
    "a script named .jpg" => ["name" => "pic.jpg", "tmp_name" => "evil.tmp", "error" => 0, "size" => 20],
    "too large" => ["name" => "big.png", "tmp_name" => "big.tmp", "error" => 0, "size" => 60000],
    "no file chosen" => ["name" => "", "tmp_name" => "", "error" => UPLOAD_ERR_NO_FILE, "size" => 0],
];

foreach ($attempts as $label => $file) {
    try {
        $id = $manager->handle($file);
        echo "$label: saved as #$id\n";
    } catch (UploadException $e) {
        echo "$label: " . $e->getMessage() . "\n";
    }
}

$row = $manager->find(1);

echo $row["original_name"] . " / " . $row["mime"] . "\n";
echo preg_match('/^[a-f0-9]{32}\.png$/', $row["stored_name"]) === 1 ? "stored under a random name\n" : "unexpected name\n";
echo is_file($manager->path($row)) ? "file exists on disk\n" : "missing\n";

$manager->delete(1);

echo is_file($manager->path($row)) ? "still on disk" : "removed from disk and database";
Output
OUTPUT
a real image: saved as #1
a script named .jpg: Only PNG, JPEG, WebP and PDF files are allowed.
too large: The file must be between 1 byte and 0.1 MB.
no file chosen: Please choose a file.
holiday photo.png / image/png
stored under a random name
file exists on disk
removed from disk and database

Every attack was stopped: the PHP script pretending to be a JPEG, the oversized file and the missing file. The path-trick name ../../holiday photo.png was reduced to holiday photo.png.

The pages

index.php
PHP
<?php
declare(strict_types=1);

session_start();

require __DIR__ . "/functions.php";
require __DIR__ . "/UploadManager.php";

$manager = new UploadManager(require __DIR__ . "/db.php", dirname(__DIR__) . "/storage/uploads");
$error = null;

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    csrfCheck();

    try {
        $manager->handle($_FILES["file"] ?? []);
        $_SESSION["flash"] = "File uploaded.";
        header("Location: index.php");
        exit;
    } catch (UploadException $e) {
        $error = $e->getMessage();
    }
}

$flash = $_SESSION["flash"] ?? null;
unset($_SESSION["flash"]);
?>
<h1>Files</h1>

<?php if ($flash): ?><p role="status"><?= h($flash) ?></p><?php endif; ?>
<?php if ($error): ?><p role="alert"><?= h($error) ?></p><?php endif; ?>

<form method="post" enctype="multipart/form-data">
  <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
  <label for="file">Choose a file (PNG, JPEG, WebP or PDF, up to 2 MB)</label>
  <input type="file" id="file" name="file" accept=".png,.jpg,.jpeg,.webp,.pdf" required>
  <button type="submit">Upload</button>
</form>

<ul>
<?php foreach ($manager->all() as $f): ?>
  <li>
    <a href="download.php?id=<?= (int) $f["id"] ?>"><?= h($f["original_name"]) ?></a>
    (<?= h($f["mime"]) ?>, <?= number_format($f["size"] / 1024, 1) ?> KB)
    <form method="post" action="delete.php" style="display:inline">
      <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
      <input type="hidden" name="id" value="<?= (int) $f["id"] ?>">
      <button>Delete</button>
    </form>
  </li>
<?php endforeach; ?>
</ul>
download.php
PHP
<?php
declare(strict_types=1);

require __DIR__ . "/UploadManager.php";

$manager = new UploadManager(require __DIR__ . "/db.php", dirname(__DIR__) . "/storage/uploads");

$id = filter_var($_GET["id"] ?? null, FILTER_VALIDATE_INT);
$row = $id === false ? null : $manager->find($id);

if ($row === null || !is_file($manager->path($row))) {
    http_response_code(404);
    exit("File not found.");
}

$downloadName = str_replace(['"', "\r", "\n"], "", $row["original_name"]);

header("Content-Type: " . $row["mime"]);
header("Content-Length: " . $row["size"]);
header("X-Content-Type-Options: nosniff");
header('Content-Disposition: attachment; filename="' . $downloadName . '"');

readfile($manager->path($row));

attachment makes the browser download instead of displaying the file. nosniff stops it from guessing a different type. The path always comes from the database row (a name that we generated), never from the request.

What you practised

  • Secure uploads: finfo, allow-list, size limit, random name, storage outside the web root.
  • Dependency injection of the mover, so the class can be tested.
  • Serving downloads with safe headers.
  • Keeping the database row and the file on disk in step.

Challenges

  1. Add a per-user file list and permissions (Protecting Pages).
  2. Create thumbnails of images with GD, and re-encode them to remove hidden data.
  3. Add a storage quota per user.
  4. Scan uploads with ClamAV if your server has it.