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 stored name is generated, so a hostile original name cannot traverse directories or overwrite anything. The original is kept as data for display.
- The hash gives you free deduplication and a way to detect corruption later.
- The MIME type is the detected one from
finfo, never the browser's claim.
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.
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
| Filesystem | Database BLOB | Object storage | |
|---|---|---|---|
| Best size range | Any | Under ~1 MB | Any |
| Transactional with metadata | No | Yes | No |
| Multi-server | Needs shared storage | Works | Works |
| Backup | Separate job | Included in the dump | Provider handles it |
| Serving cost | Cheap | Expensive (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.
