Why Secure File Uploads Matter
Insecure file uploads are one of the top attack vectors for web applications. Without proper validation, attackers can upload malicious scripts, overwrite existing files, or exploit your server. This tutorial focuses on security-first file handling:
- Server-side MIME type verification (never trust client-side checks alone)
- File extension whitelist to block dangerous file types
- File size limits to prevent denial-of-service attacks
- Randomized file names to prevent path traversal and overwrites
- Image dimension validation for profile pictures and thumbnails
- Secure upload directory configuration with no script execution
- Single and multiple file upload handling with progress feedback

Table Of Content
1 Prerequisites
- PHP 8.0+ installed
- Apache or PHP built-in server
- Basic PHP and HTML knowledge
- GD or Imagick extension (for image validation — usually pre-installed)
- fileinfo extension enabled (for MIME type detection — enabled by default in PHP 8+)
2 How PHP File Uploads Work
When a user submits a form with enctype="multipart/form-data", PHP stores the uploaded file in a temporary directory and populates the $_FILES superglobal array with file metadata:
$_FILES["fieldname"] contains:
name — The original file name from the user's computer
type — The MIME type reported by the browser (DO NOT trust this alone!)
tmp_name — Path to the temporary file on the server
error — Error code (0 = success, 1-8 = various errors)
size — File size in bytes
The upload process:
1. User selects a file and submits the form.
2. PHP receives the file and stores it in a temp directory (sys_get_temp_dir()).
3. Your code validates the file (type, size, extension, content).
4. If valid, move it from temp to your upload directory using move_uploaded_file().
5. If invalid, reject it and show an error message.
Critical rule: Never trust the file extension or browser-reported MIME type alone. Always verify the actual file content using PHP's finfo functions.
3 Project Setup
3.1 Create Project Structure
Create the project folder and subdirectories:
mkdir php-file-upload
cd php-file-upload
mkdir classes
mkdir uploads
mkdir uploads/images
mkdir uploads/documents
Make sure the uploads directory is writable by the web server:
chmod -R 755 uploads/
3.2 PHP Configuration (php.ini)
Ensure these settings are configured in your php.ini file:
file_uploads = On
upload_max_filesize = 10M
post_max_size = 12M
max_file_uploads = 10
memory_limit = 128M
max_execution_time = 60
Important notes:post_max_size should always be slightly larger than upload_max_filesize to account for form overhead.
max_file_uploads controls how many files can be uploaded in a single request.
After changing php.ini, restart your web server (Apache/Nginx) for changes to take effect.
4 Secure File Upload Class
Create classes/FileUploader.php — this reusable class handles all upload logic with built-in security:
<?php
class FileUploader
{
private string $uploadDir;
private array $allowedMimeTypes;
private array $allowedExtensions;
private int $maxFileSize;
private int $maxImageWidth;
private int $maxImageHeight;
private array $errors = [];
private array $uploadedFiles = [];
// Predefined allowed types
public const IMAGE_TYPES = [
"mime" => ["image/jpeg", "image/png", "image/gif", "image/webp"],
"extensions" => ["jpg", "jpeg", "png", "gif", "webp"]
];
public const DOCUMENT_TYPES = [
"mime" => ["application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/plain", "text/csv"],
"extensions" => ["pdf", "doc", "docx", "xls", "xlsx", "txt", "csv"]
];
public function __construct(array $config = [])
{
$this->uploadDir = rtrim($config["upload_dir"] ?? "uploads/", "/") . "/";
$this->allowedMimeTypes = $config["allowed_mime"] ?? self::IMAGE_TYPES["mime"];
$this->allowedExtensions = $config["allowed_ext"] ?? self::IMAGE_TYPES["extensions"];
$this->maxFileSize = $config["max_size"] ?? 5 * 1024 * 1024; // 5MB default
$this->maxImageWidth = $config["max_width"] ?? 4096;
$this->maxImageHeight = $config["max_height"] ?? 4096;
// Create upload directory if it doesn't exist
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
/**
* Upload a single file
*/
public function upload(array $file): ?array
{
$this->errors = [];
// Check for upload errors
if ($file["error"] !== UPLOAD_ERR_OK) {
$this->errors[] = $this->getUploadErrorMessage($file["error"]);
return null;
}
// Validate file
if (!$this->validate($file)) {
return null;
}
// Generate secure file name
$extension = $this->getSecureExtension($file["name"]);
$newFileName = $this->generateSecureName($extension);
$destination = $this->uploadDir . $newFileName;
// Move the file
if (!move_uploaded_file($file["tmp_name"], $destination)) {
$this->errors[] = "Failed to move uploaded file";
return null;
}
$result = [
"original_name" => $file["name"],
"saved_name" => $newFileName,
"path" => $destination,
"size" => $file["size"],
"size_readable" => $this->formatFileSize($file["size"]),
"mime_type" => $this->getActualMimeType($destination),
"extension" => $extension
];
// Add image dimensions if it's an image
if ($this->isImage($destination)) {
$imageInfo = getimagesize($destination);
$result["width"] = $imageInfo[0];
$result["height"] = $imageInfo[1];
}
return $result;
}
/**
* Upload multiple files
*/
public function uploadMultiple(array $files): array
{
$this->uploadedFiles = [];
$this->errors = [];
// Reorganize $_FILES array for multiple uploads
$fileCount = count($files["name"]);
for ($i = 0; $i < $fileCount; $i++) {
$file = [
"name" => $files["name"][$i],
"type" => $files["type"][$i],
"tmp_name" => $files["tmp_name"][$i],
"error" => $files["error"][$i],
"size" => $files["size"][$i]
];
$result = $this->upload($file);
if ($result) {
$this->uploadedFiles[] = $result;
}
}
return $this->uploadedFiles;
}
/**
* Validate the uploaded file
*/
private function validate(array $file): bool
{
$valid = true;
// 1. Check file size
if ($file["size"] > $this->maxFileSize) {
$this->errors[] = "File \"{$file["name"]}\" exceeds the maximum size of " . $this->formatFileSize($this->maxFileSize);
$valid = false;
}
// 2. Check file extension (whitelist)
$extension = $this->getSecureExtension($file["name"]);
if (!in_array($extension, $this->allowedExtensions, true)) {
$this->errors[] = "File extension \".{$extension}\" is not allowed. Allowed: " . implode(", ", $this->allowedExtensions);
$valid = false;
}
// 3. Check actual MIME type using finfo (NOT browser-reported type)
$actualMime = $this->getActualMimeType($file["tmp_name"]);
if (!in_array($actualMime, $this->allowedMimeTypes, true)) {
$this->errors[] = "File type \"{$actualMime}\" is not allowed for \"{$file["name"]}\"";
$valid = false;
}
// 4. For images — validate dimensions and that it's a real image
if ($valid && $this->isImageMime($actualMime)) {
$imageInfo = @getimagesize($file["tmp_name"]);
if ($imageInfo === false) {
$this->errors[] = "File \"{$file["name"]}\" is not a valid image";
$valid = false;
} else {
if ($imageInfo[0] > $this->maxImageWidth || $imageInfo[1] > $this->maxImageHeight) {
$this->errors[] = "Image dimensions exceed maximum {$this->maxImageWidth}x{$this->maxImageHeight}px";
$valid = false;
}
}
}
// 5. Check for PHP code in file content (prevent disguised scripts)
$content = file_get_contents($file["tmp_name"], false, null, 0, 1024);
if (preg_match('/(<\?php|<\?=|
