Skip to content

Storing Uploaded Files: Database or Filesystem?

Once the upload succeeds you have to put the bytes somewhere. The three real options are the filesystem, a database BLOB and object storage, and the right answer is usually a combination — bytes in one place, metadata in another.

The default: filesystem plus a metadata row

Store the bytes on disk and everything you know about them in the database. It suits almost every application:

CREATE TABLE uploads (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id       BIGINT UNSIGNED NOT NULL,
    stored_name   VARCHAR(64)  NOT NULL,       -- generated, on disk
    original_name VARCHAR(255) NOT NULL,       -- display only
    mime_type     VARCHAR(100) NOT NULL,       -- detected, not reported
    byte_size     BIGINT UNSIGNED NOT NULL,
    sha256        CHAR(64) NOT NULL,
    created_at    DATETIME NOT NULL,
    UNIQUE KEY uq_stored (stored_name),
    KEY ix_user (user_id),
    KEY ix_hash (sha256)
) ENGINE=InnoDB;
$stored = bin2hex(random_bytes(16)) . '.' . $ext;
$path   = UPLOAD_DIR . '/' . $stored;         // outside the web root

if (!move_uploaded_file($file['tmp_name'], $path)) {
    throw new RuntimeException('Could not store the file.');
}

$stmt = $pdo->prepare(
    'INSERT INTO uploads (user_id, stored_name, original_name, mime_type, byte_size, sha256, created_at)
     VALUES (?, ?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([$userId, $stored, $file['name'], $mime, $file['size'], hash_file('sha256', $path)]);

Why this shape:

The consistency problem, and how to handle it

Two stores mean they can disagree. A crash between move_uploaded_file() and the INSERT leaves an orphaned file; a rolled-back transaction after a successful move leaves the same. Order the operations so the failure mode is harmless:

$pdo->beginTransaction();
try {
    $stmt->execute([...]);                    // row first
    if (!move_uploaded_file($file['tmp_name'], $path)) {
        throw new RuntimeException('write failed');
    }
    $pdo->commit();                           // bytes are on disk before we commit
} catch (Throwable $e) {
    $pdo->rollBack();
    @unlink($path);
    throw $e;
}

An orphaned file with no row is harmless — a sweeper deletes anything on disk with no matching row and an old mtime. An orphaned row with no file is worse, because the application will try to serve something that is not there. Prefer the ordering that risks the former.

Do not put thousands of files in one directory

Directory listings degrade badly past a few thousand entries on some filesystems. Shard by the first characters of the name, which is free given the names are already random hex:

$shard = substr($stored, 0, 2) . '/' . substr($stored, 2, 2);   // 'a3/f9'
$dir   = UPLOAD_DIR . '/' . $shard;
is_dir($dir) || mkdir($dir, 0755, true);

When a BLOB column makes sense

Storing bytes in the database is unfashionable, and occasionally correct. It wins when transactional consistency matters more than throughput: the file and its row commit or roll back together, replication and backups carry both, and there is no second store to keep in sync. Good fits are small files — signatures, avatars, generated PDFs — especially where a shared filesystem would otherwise be needed across several servers.

-- MySQL: TINYBLOB 255 B, BLOB 64 KB, MEDIUMBLOB 16 MB, LONGBLOB 4 GB
ALTER TABLE uploads ADD COLUMN content LONGBLOB NULL;

Stream it in rather than reading the file into a PHP string:

$fh = fopen($path, 'rb');
$stmt = $pdo->prepare('UPDATE uploads SET content = ? WHERE id = ?');
$stmt->bindParam(1, $fh, PDO::PARAM_LOB);
$stmt->bindValue(2, $id, PDO::PARAM_INT);
$stmt->execute();
fclose($fh);

And stream it back out:

$stmt = $pdo->prepare('SELECT mime_type, original_name, content FROM uploads WHERE id = ?');
$stmt->execute([$id]);
$stmt->bindColumn('content', $content, PDO::PARAM_LOB);
$row = $stmt->fetch(PDO::FETCH_BOUND);

header('Content-Type: ' . $row['mime_type']);
header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
is_resource($content) ? fpassthru($content) : print($content);

Two practical limits. MySQL's max_allowed_packet (commonly 64 MB) caps a single statement, so larger blobs fail with a packet error rather than a size error. And SELECT * on a table with blobs will quietly drag megabytes into memory — keep the blob in its own table, or always name your columns.

Rule of thumb: under about 1 MB and transactional integrity matters — a BLOB is defensible. Above that, or where files are served frequently, the filesystem or object storage wins on both memory and cost.

Object storage

For anything at scale, or any deployment with more than one application server, S3-compatible storage removes the shared-filesystem problem entirely: the bytes live in a bucket, the metadata row keeps a key instead of a path, and the files survive the servers.

$key = 'uploads/' . $shard . '/' . $stored;

$s3->putObject([
    'Bucket'      => 'my-app-uploads',
    'Key'         => $key,
    'SourceFile'  => $path,           // streams from disk
    'ContentType' => $mime,
]);

Keep the bucket private and hand out short-lived pre-signed URLs rather than making objects public. That keeps authorisation in your application while the bytes are served directly by the storage provider:

$cmd = $s3->getCommand('GetObject', ['Bucket' => 'my-app-uploads', 'Key' => $key]);
$url = (string) $s3->createPresignedRequest($cmd, '+15 minutes')->getUri();

Serving files safely, wherever they live

If the bytes sit outside the web root or in a bucket, requests go through a PHP endpoint that checks authorisation first:

$row = $repo->findFor($_GET['id'], $currentUserId);   // authorisation in the query
if ($row === null) { http_response_code(404); exit; }

header('Content-Type: ' . $row['mime_type']);
header('Content-Length: ' . $row['byte_size']);
header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile(UPLOAD_DIR . '/' . $row['stored_name']);

Never accept a path from the query string — look the record up by id and build the path yourself. For large files on Nginx or Apache, hand the transfer to the web server with X-Accel-Redirect or X-Sendfile so PHP is not tied up streaming bytes.

Comparison

FilesystemDatabase BLOBObject storage
Best size rangeAnyUnder ~1 MBAny
Transactional with metadataNoYesNo
Multi-serverNeeds shared storageWorksWorks
BackupSeparate jobIncluded in the dumpProvider handles it
Serving costCheapExpensive (DB in the path)Cheap, offloaded

Where the file comes from

All of this starts with the upload arriving intact. PHP File Uploader gives you the finished file server-side — through GetUploadedFile(), or your own handler via UploadUrl — so you can move it to disk, stream it into a BLOB or push it to a bucket with the code above. See the custom handler demo and the keeping-state demo, which shows looking files up again after a post.