📎 Lesson 22: File Uploads & Email
Most web applications need to handle files from users — profile pictures, documents, attachments — and communicate via email: confirmation messages, password resets, contact forms. In this lesson, you'll learn to accept file uploads securely with PHP's $_FILES superglobal, validate them thoroughly, move them to permanent storage, and send email using the built-in mail() function.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Build an HTML form that supports file uploads with
enctype="multipart/form-data" - Read and interpret the
$_FILESsuperglobal array - Validate uploads by file type (MIME), extension, and size
- Move uploaded files safely with
move_uploaded_file() - Handle multiple file uploads in a single form
- Send email with PHP's
mail()function - Build a contact form that processes input, validates it, and sends an email
Estimated Time: 45 minutes
Prerequisites: Lessons 11–12 (forms, validation), Lesson 14 (superglobals)
📑 In This Lesson
How File Uploads Work
When a user selects a file and submits a form, the browser packages the file data and sends it to the server as part of the HTTP request. PHP receives the file, stores it in a temporary directory, and populates the $_FILES superglobal with information about it. Your job is to validate the file and move it from the temporary location to a permanent one.
There are two essential requirements for a file upload form:
- The form method must be
POST(files can't be sent via GET) - The form must include
enctype="multipart/form-data"— this tells the browser to encode the file data properly instead of treating it as a URL-encoded string
<!-- The simplest possible file upload form -->
<form method="POST" action="upload.php" enctype="multipart/form-data">
<label for="avatar">Choose a profile picture:</label>
<input type="file" id="avatar" name="avatar">
<button type="submit">Upload</button>
</form>
⚠️ Forget enctype? Files Won't Upload
Without enctype="multipart/form-data", the browser sends only the filename as a plain text string, not the file contents. $_FILES will be empty and you'll spend an hour debugging. This is the #1 file upload mistake.
The $_FILES Superglobal
When PHP receives an uploaded file, it creates an entry in $_FILES keyed by the name attribute of the input. Each entry is an associative array with five elements:
<?php
// After submitting a form with <input type="file" name="avatar">
// $_FILES["avatar"] contains:
[
"name" => "photo.jpg", // Original filename from the user's computer
"type" => "image/jpeg", // MIME type reported by the browser
"tmp_name" => "/tmp/phpA1B2C3", // Temporary path on the server
"error" => 0, // Error code (0 = success)
"size" => 245678, // File size in bytes
]
| Key | Description | Example |
|---|---|---|
name |
The original filename on the user's machine | "vacation_photo.jpg" |
type |
The MIME type reported by the browser (not trustworthy!) | "image/jpeg" |
tmp_name |
The temporary path where PHP stored the file | "/tmp/phpA1B2C3" |
error |
An error code — 0 means no error | UPLOAD_ERR_OK (0) |
size |
File size in bytes | 245678 (≈240 KB) |
Upload Error Codes
Always check the error field before processing a file. PHP defines these constants:
| Constant | Value | Meaning |
|---|---|---|
UPLOAD_ERR_OK |
0 | Upload successful — no error |
UPLOAD_ERR_INI_SIZE |
1 | File exceeds upload_max_filesize in php.ini |
UPLOAD_ERR_FORM_SIZE |
2 | File exceeds MAX_FILE_SIZE set in the HTML form |
UPLOAD_ERR_PARTIAL |
3 | File was only partially uploaded (connection interrupted) |
UPLOAD_ERR_NO_FILE |
4 | No file was selected (form submitted empty) |
UPLOAD_ERR_NO_TMP_DIR |
6 | Server has no temp directory configured |
UPLOAD_ERR_CANT_WRITE |
7 | Failed to write file to disk (permissions issue) |
UPLOAD_ERR_EXTENSION |
8 | A PHP extension stopped the upload |
<?php
// Handy function to convert error codes to readable messages
function uploadErrorMessage(int $code): string {
return match ($code) {
UPLOAD_ERR_OK => "No error.",
UPLOAD_ERR_INI_SIZE => "File exceeds the server's maximum upload size.",
UPLOAD_ERR_FORM_SIZE => "File exceeds the form's maximum upload size.",
UPLOAD_ERR_PARTIAL => "File was only partially uploaded.",
UPLOAD_ERR_NO_FILE => "No file was uploaded.",
UPLOAD_ERR_NO_TMP_DIR => "Server error: missing temp directory.",
UPLOAD_ERR_CANT_WRITE => "Server error: failed to write to disk.",
UPLOAD_ERR_EXTENSION => "Upload blocked by a PHP extension.",
default => "Unknown upload error.",
};
}
Validating Uploads
Never trust an uploaded file blindly. Users (and attackers) can upload anything — a disguised PHP script pretending to be an image, an oversized file, or a file with a malicious name. You need to validate at least three things:
- Error code — Was the upload itself successful?
- File type — Is it an allowed MIME type? (Verify on the server, not just the browser's claim)
- File size — Is it within your allowed limit?
Checking the MIME Type Properly
The $_FILES["avatar"]["type"] value comes from the browser and can be faked. Instead, use finfo_file() (the Fileinfo extension) to inspect the file's actual contents:
<?php
// ❌ Don't trust the browser-reported type
$browserType = $_FILES["avatar"]["type"]; // Could be faked!
// ✅ Use finfo to check the actual file content
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realType = $finfo->file($_FILES["avatar"]["tmp_name"]);
echo $realType; // e.g., "image/jpeg" — based on file contents, not extension
📖 How finfo Works
finfo reads the file's magic bytes — the first few bytes of a file that identify its format. A JPEG file always starts with FF D8 FF, a PNG with 89 50 4E 47, a PDF with %PDF. This is much more reliable than checking the file extension, which is just part of the filename and means nothing to the file system.
Complete Validation Function
<?php
/**
* Validate an uploaded file.
*
* @param array $file The $_FILES entry (e.g., $_FILES["avatar"])
* @param array $allowedTypes Permitted MIME types
* @param int $maxSize Maximum file size in bytes
* @return string|null Error message, or null if valid
*/
function validateUpload(array $file, array $allowedTypes, int $maxSize): ?string {
// 1. Check the upload error code
if ($file["error"] !== UPLOAD_ERR_OK) {
return uploadErrorMessage($file["error"]);
}
// 2. Verify the file was actually uploaded (security check)
if (!is_uploaded_file($file["tmp_name"])) {
return "Invalid upload — file did not come from a form submission.";
}
// 3. Check the real MIME type using file contents
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file["tmp_name"]);
if (!in_array($mimeType, $allowedTypes, true)) {
return "File type '$mimeType' is not allowed. Accepted: "
. implode(", ", $allowedTypes);
}
// 4. Check file size
if ($file["size"] > $maxSize) {
$maxMB = round($maxSize / 1048576, 1); // bytes → MB
return "File is too large. Maximum size: {$maxMB} MB.";
}
return null; // All checks passed
}
⚠️ Why is_uploaded_file() Matters
is_uploaded_file() verifies that the file at tmp_name was genuinely uploaded via HTTP POST. Without it, an attacker who can manipulate $_FILES values could trick your script into moving arbitrary files (like /etc/passwd) to your uploads directory. Always check before calling move_uploaded_file().
Moving Files to Permanent Storage
Uploaded files land in PHP's temp directory (usually /tmp). They're automatically deleted when the script ends, so you need to move them to a permanent location before that happens.
move_uploaded_file()
<?php
// move_uploaded_file(string $from, string $to): bool
// - Only works on files uploaded via HTTP POST
// - Returns true on success, false on failure
$tmpPath = $_FILES["avatar"]["tmp_name"];
$destination = __DIR__ . "/uploads/avatar_12345.jpg";
if (move_uploaded_file($tmpPath, $destination)) {
echo "File saved successfully!";
} else {
echo "Failed to save the file.";
}
Generating Safe Filenames
Never use the original filename directly — it could contain path traversal characters (../../etc/passwd), spaces, special characters, or collide with existing files. Generate a unique filename instead:
<?php
/**
* Generate a safe, unique filename for an uploaded file.
*
* @param string $originalName The original filename (e.g., "My Photo!.jpg")
* @param string $uploadDir The target directory
* @return string Safe filename (e.g., "662f1a3b_my-photo.jpg")
*/
function safeFilename(string $originalName, string $uploadDir): string {
// Extract and sanitize the extension
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$ext = preg_replace("/[^a-z0-9]/", "", $ext); // Only alphanumeric
// Create a clean base name
$base = pathinfo($originalName, PATHINFO_FILENAME);
$base = strtolower(trim($base));
$base = preg_replace("/[^a-z0-9\-]/", "-", $base); // Replace special chars
$base = preg_replace("/-+/", "-", $base); // Collapse multiple dashes
$base = substr($base, 0, 50); // Limit length
// Add a unique prefix to prevent collisions
$prefix = substr(bin2hex(random_bytes(4)), 0, 8);
$filename = "{$prefix}_{$base}.{$ext}";
return $uploadDir . DIRECTORY_SEPARATOR . $filename;
}
// Usage:
$safePath = safeFilename(
$_FILES["avatar"]["name"], // "My Vacation Photo!!.JPG"
__DIR__ . "/uploads" // "/var/www/html/uploads"
);
// Result: "/var/www/html/uploads/a3f7b21c_my-vacation-photo.jpg"
Creating the Upload Directory
<?php
$uploadDir = __DIR__ . "/uploads";
// Create the directory if it doesn't exist
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true); // 0755 = owner rwx, group rx, others rx
}
// IMPORTANT: Prevent direct access to uploaded files via .htaccess
// Create an .htaccess file in the uploads directory:
//
// # uploads/.htaccess
// # Deny execution of PHP files in this directory
// php_flag engine off
//
// Or use Apache configuration to deny script execution in the uploads folder.
✅ The Upload Directory Checklist
- Exists: Create it with
mkdir()if needed - Writable: The web server user (www-data) must have write permission
- No PHP execution: Use
.htaccessto block PHP execution inside the uploads folder - Outside document root (ideal): In production, store uploads outside the web-accessible directory and serve them through a PHP script
Multiple File Uploads
To accept multiple files, add the multiple attribute and use array notation ([]) in the input name:
<form method="POST" action="upload.php" enctype="multipart/form-data">
<label for="photos">Select photos (up to 5):</label>
<input type="file" id="photos" name="photos[]" multiple accept="image/*">
<button type="submit">Upload All</button>
</form>
When multiple files are uploaded, $_FILES structures the data differently — each property becomes an array:
<?php
// $_FILES["photos"] for 3 uploaded files looks like this:
[
"name" => ["beach.jpg", "sunset.png", "mountain.jpg"],
"type" => ["image/jpeg", "image/png", "image/jpeg"],
"tmp_name" => ["/tmp/phpABC", "/tmp/phpDEF", "/tmp/phpGHI"],
"error" => [0, 0, 0],
"size" => [125000, 340000, 210000],
]
This structure is awkward to work with — the data for file #2 is spread across five separate arrays. Here's a helper function that reorganizes it into a more natural format:
<?php
/**
* Reorganize $_FILES for multiple uploads into a cleaner structure.
*
* Converts the default PHP structure into an array of individual file arrays,
* each with "name", "type", "tmp_name", "error", and "size" keys.
*/
function reorganizeFiles(array $filesArray): array {
$result = [];
$fileCount = count($filesArray["name"]);
for ($i = 0; $i < $fileCount; $i++) {
// Skip empty slots (user selected fewer files than max)
if ($filesArray["error"][$i] === UPLOAD_ERR_NO_FILE) {
continue;
}
$result[] = [
"name" => $filesArray["name"][$i],
"type" => $filesArray["type"][$i],
"tmp_name" => $filesArray["tmp_name"][$i],
"error" => $filesArray["error"][$i],
"size" => $filesArray["size"][$i],
];
}
return $result;
}
// Usage:
$photos = reorganizeFiles($_FILES["photos"]);
foreach ($photos as $index => $photo) {
echo "File $index: {$photo['name']} ({$photo['size']} bytes)\n";
// Now you can pass each $photo to validateUpload() individually
}
Complete Upload Example
Let's put everything together into a working image upload page. This handles single or multiple images, validates each one, moves them to the uploads directory, and displays feedback.
<?php
// File: image_upload.php
$uploadDir = __DIR__ . "/uploads";
$allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
$maxSize = 5 * 1024 * 1024; // 5 MB
$maxFiles = 5;
$results = []; // Track success/error for each file
$errors = [];
// Create upload directory if needed
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// ---- Handle form submission ----
if ($_SERVER["REQUEST_METHOD"] === "POST" && !empty($_FILES["images"])) {
$files = reorganizeFiles($_FILES["images"]);
// Check total file count
if (count($files) > $maxFiles) {
$errors[] = "You can upload a maximum of $maxFiles files at once.";
} else {
foreach ($files as $i => $file) {
// Validate each file
$error = validateUpload($file, $allowedTypes, $maxSize);
if ($error) {
$results[] = [
"name" => $file["name"],
"status" => "error",
"message" => $error,
];
continue;
}
// Generate safe filename and move
$destination = safeFilename($file["name"], $uploadDir);
if (move_uploaded_file($file["tmp_name"], $destination)) {
$results[] = [
"name" => $file["name"],
"status" => "success",
"message" => "Saved as " . basename($destination),
"path" => "uploads/" . basename($destination),
];
} else {
$results[] = [
"name" => $file["name"],
"status" => "error",
"message" => "Failed to save file to disk.",
];
}
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Image Upload</title>
<style>
body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; }
.success { color: #166534; background: #dcfce7; padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
.error { color: #991b1b; background: #fee2e2; padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
.preview { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
.preview img { max-width: 150px; border-radius: 8px; }
</style>
</head>
<body>
<h1>Image Upload</h1>
<form method="POST" enctype="multipart/form-data">
<p>
<label for="images">Select images (max 5, up to 5 MB each):</label><br>
<input type="file" id="images" name="images[]" multiple accept="image/*">
</p>
<button type="submit">Upload</button>
</form>
<?php if ($errors): ?>
<?php foreach ($errors as $err): ?>
<div class="error"><?= htmlspecialchars($err) ?></div>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($results): ?>
<h2>Results</h2>
<?php foreach ($results as $r): ?>
<div class="<?= $r['status'] ?>">
<strong><?= htmlspecialchars($r['name']) ?>:</strong>
<?= htmlspecialchars($r['message']) ?>
</div>
<?php endforeach; ?>
<!-- Preview successfully uploaded images -->
<div class="preview">
<?php foreach ($results as $r): ?>
<?php if ($r["status"] === "success" && isset($r["path"])): ?>
<img src="<?= htmlspecialchars($r['path']) ?>"
alt="<?= htmlspecialchars($r['name']) ?>">
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</body>
</html>
💡 Note: This example assumes theuploadErrorMessage(),validateUpload(),reorganizeFiles(), andsafeFilename()functions from earlier sections are defined at the top of the file (or in an included helper file).
Sending Email with mail()
PHP includes a built-in mail() function that sends email through the server's mail system. It's simple and great for learning, though production applications typically use a library like PHPMailer or a service like SendGrid for better deliverability and features.
The mail() Function
<?php
// mail(to, subject, message, headers): bool
$to = "admin@example.com";
$subject = "Test Email from PHP";
$message = "Hello!\n\nThis is a test email sent from PHP's mail() function.\n\nBest regards,\nYour App";
$headers = "From: noreply@example.com\r\n"
. "Reply-To: user@example.com\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "X-Mailer: PHP/" . phpversion();
$sent = mail($to, $subject, $message, $headers);
if ($sent) {
echo "Email sent successfully!";
} else {
echo "Failed to send email.";
}
| Parameter | Description | Example |
|---|---|---|
$to |
Recipient email address | "admin@example.com" |
$subject |
Email subject line | "New Contact Form Message" |
$message |
Email body (plain text or HTML) | "Hello, this is..." |
$headers |
Additional headers (From, Reply-To, Content-Type, etc.) | "From: noreply@example.com" |
Sending HTML Email
<?php
$to = "admin@example.com";
$subject = "Welcome to Our App!";
// HTML email body
$message = '
<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2 style="color: #3b82f6;">Welcome!</h2>
<p>Thank you for signing up. Here are your next steps:</p>
<ol>
<li>Complete your profile</li>
<li>Explore the dashboard</li>
<li>Create your first project</li>
</ol>
<p><a href="https://example.com/dashboard" style="color: #3b82f6;">Go to Dashboard</a></p>
</body>
</html>';
// Headers — MUST set Content-Type to text/html
$headers = "From: noreply@example.com\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
mail($to, $subject, $message, $headers);
⚠️ mail() Limitations
- Requires a mail server:
mail()uses the server's local mail transfer agent (like Sendmail or Postfix). Many local dev environments and shared hosts don't have one configured properly. - No SMTP authentication:
mail()doesn't support connecting to Gmail, Outlook, or other SMTP servers directly. - Deliverability issues: Emails sent via
mail()often land in spam because the server IP may not have proper SPF/DKIM records. - No attachments (natively): Sending attachments requires manually building MIME boundaries — much harder than using a library.
For production apps, use PHPMailer (installed via Composer) or a transactional email service like SendGrid, Mailgun, or Amazon SES.
📖 Testing Email Locally
You don't need a real mail server for development. Tools like Mailpit or MailHog run a local fake SMTP server that catches all outgoing email and displays it in a web interface. Install Mailpit, configure php.ini to route sendmail_path through it, and every mail() call will show up in your browser at http://localhost:8025.
Building a Contact Form
Let's combine form handling, validation, and email into a practical contact form. This is something almost every website needs.
<?php
// File: contact.php
$errors = [];
$success = false;
$old = [
"name" => "",
"email" => "",
"subject" => "",
"message" => "",
];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// 1. Collect and trim input
$old["name"] = trim($_POST["name"] ?? "");
$old["email"] = trim($_POST["email"] ?? "");
$old["subject"] = trim($_POST["subject"] ?? "");
$old["message"] = trim($_POST["message"] ?? "");
// 2. Validate
if ($old["name"] === "") {
$errors["name"] = "Name is required.";
} elseif (mb_strlen($old["name"]) > 100) {
$errors["name"] = "Name must be 100 characters or fewer.";
}
if ($old["email"] === "") {
$errors["email"] = "Email is required.";
} elseif (!filter_var($old["email"], FILTER_VALIDATE_EMAIL)) {
$errors["email"] = "Please enter a valid email address.";
}
if ($old["subject"] === "") {
$errors["subject"] = "Subject is required.";
}
if ($old["message"] === "") {
$errors["message"] = "Message is required.";
} elseif (mb_strlen($old["message"]) > 5000) {
$errors["message"] = "Message must be 5,000 characters or fewer.";
}
// 3. If valid → send email
if (empty($errors)) {
$to = "admin@example.com"; // Change to your email
$emailSubject = "Contact Form: " . $old["subject"];
$emailBody = "You received a new contact form submission:\n\n";
$emailBody .= "Name: {$old['name']}\n";
$emailBody .= "Email: {$old['email']}\n";
$emailBody .= "Subject: {$old['subject']}\n\n";
$emailBody .= "Message:\n{$old['message']}\n";
$headers = "From: noreply@example.com\r\n"
. "Reply-To: {$old['email']}\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n";
// Send the email
$sent = mail($to, $emailSubject, $emailBody, $headers);
if ($sent) {
$success = true;
// Clear form after successful send
$old = ["name" => "", "email" => "", "subject" => "", "message" => ""];
} else {
$errors["general"] = "Failed to send email. Please try again later.";
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Contact Us</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; }
.form-group { margin-bottom: 1rem; }
label { display: block; font-weight: bold; margin-bottom: 0.25rem; }
input, textarea, select { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
textarea { min-height: 150px; }
.error { color: #dc2626; font-size: 0.85rem; }
.success-msg { background: #dcfce7; color: #166534; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; }
.error-msg { background: #fee2e2; color: #991b1b; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; }
button { background: #3b82f6; color: white; padding: 0.75rem 1.5rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
button:hover { background: #2563eb; }
</style>
</head>
<body>
<h1>Contact Us</h1>
<?php if ($success): ?>
<div class="success-msg">
Thank you! Your message has been sent. We'll get back to you soon.
</div>
<?php endif; ?>
<?php if (isset($errors["general"])): ?>
<div class="error-msg"><?= htmlspecialchars($errors["general"]) ?></div>
<?php endif; ?>
<form method="POST" action="contact.php">
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name"
value="<?= htmlspecialchars($old['name']) ?>" required>
<?php if (isset($errors["name"])): ?>
<p class="error"><?= htmlspecialchars($errors["name"]) ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email"
value="<?= htmlspecialchars($old['email']) ?>" required>
<?php if (isset($errors["email"])): ?>
<p class="error"><?= htmlspecialchars($errors["email"]) ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text" id="subject" name="subject"
value="<?= htmlspecialchars($old['subject']) ?>" required>
<?php if (isset($errors["subject"])): ?>
<p class="error"><?= htmlspecialchars($errors["subject"]) ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" required><?= htmlspecialchars($old['message']) ?></textarea>
<?php if (isset($errors["message"])): ?>
<p class="error"><?= htmlspecialchars($errors["message"]) ?></p>
<?php endif; ?>
</div>
<button type="submit">Send Message</button>
</form>
</body>
</html>
✅ Security in the Contact Form
- Email validation:
filter_var($email, FILTER_VALIDATE_EMAIL)rejects malformed addresses - Output escaping: All form values are escaped with
htmlspecialchars()before rendering - Header injection prevention: The
Reply-Toheader uses the validated email, but theFromheader is hardcoded — this prevents an attacker from injecting additional headers (like BCC) through the email field - Length limits: Prevents abuse by limiting input lengths
Hands-On Exercises
🏋️ Exercise 1: Profile Picture Upload
Objective: Build a page where users can upload a profile picture with full validation.
Requirements:
- Accept only JPEG and PNG files
- Maximum file size: 2 MB
- Generate a unique filename using
safeFilename() - Display the uploaded image after a successful upload
- Show clear error messages for invalid files
💡 Hint
Start with the single-file upload pattern. Set $allowedTypes = ["image/jpeg", "image/png"] and $maxSize = 2 * 1024 * 1024. After a successful move, store the path and display it with an <img> tag.
✅ Solution
<?php
$uploadDir = __DIR__ . "/uploads";
$allowedTypes = ["image/jpeg", "image/png"];
$maxSize = 2 * 1024 * 1024; // 2 MB
$uploadedPath = null;
$error = null;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if ($_SERVER["REQUEST_METHOD"] === "POST" && isset($_FILES["avatar"])) {
$file = $_FILES["avatar"];
$error = validateUpload($file, $allowedTypes, $maxSize);
if (!$error) {
$dest = safeFilename($file["name"], $uploadDir);
if (move_uploaded_file($file["tmp_name"], $dest)) {
$uploadedPath = "uploads/" . basename($dest);
} else {
$error = "Failed to save file.";
}
}
}
?>
<!-- Form + display logic similar to the complete example -->
<form method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/jpeg,image/png">
<button type="submit">Upload</button>
</form>
<?php if ($error): ?>
<p style="color:red"><?= htmlspecialchars($error) ?></p>
<?php elseif ($uploadedPath): ?>
<p style="color:green">Upload successful!</p>
<img src="<?= htmlspecialchars($uploadedPath) ?>" style="max-width:200px">
<?php endif; ?>
🏋️ Exercise 2: Add Attachments to the Contact Form
Objective: Extend the contact form from Section 8 to accept an optional file attachment (PDF or image, max 3 MB).
Requirements:
- Add a file input to the contact form (don't forget
enctype!) - Validate the attachment if one is provided (type + size)
- Save the attachment to an uploads directory
- Include the attachment path in the email body so the admin knows where to find it
- The attachment should be optional — the form should still work without one
💡 Hint
Check if a file was actually uploaded by testing $_FILES["attachment"]["error"] !== UPLOAD_ERR_NO_FILE. Only run validation if a file was provided. Add the saved filename to the $emailBody string.
🏋️ Exercise 3: CSV File Importer
Objective: Build a page that accepts a CSV file upload, parses it, and displays the data in an HTML table.
Requirements:
- Accept only
.csvfiles (MIME typetext/csvortext/plain) - Read the file with
fgetcsv()after moving it to the uploads directory - Use the first row as table headers
- Display the data in a styled HTML table
- Show the total number of rows imported
💡 Hint
After moving the file, open it with fopen() and loop with fgetcsv(). The first fgetcsv() call gives you the header row — use those values as <th> elements. Each subsequent call gives you a data row.
🎯 Quick Quiz
Question 1: What happens if you forget enctype="multipart/form-data" on your upload form?
Question 2: Why should you use finfo instead of $_FILES["file"]["type"] to check the MIME type?
Question 3: What does is_uploaded_file() protect against?
Question 4: Why should you avoid using the original filename when saving uploaded files?
Question 5: What is a key limitation of PHP's built-in mail() function?
Best Practices
✅ File Upload Do's
- Always validate on the server — HTML
acceptattributes are hints, not security. Validate MIME type withfinfo, check size, and verify withis_uploaded_file(). - Generate unique filenames — Never use the original filename. Prefix with a random string and sanitize the extension.
- Block PHP execution in upload directories — Use
.htaccessto disable the PHP engine, or better yet, store uploads outside the document root. - Set php.ini limits — Configure
upload_max_filesizeandpost_max_sizeto match your application's needs.post_max_sizemust be larger thanupload_max_filesize. - Clean up failed uploads — PHP deletes temp files automatically, but if you move a file and validation fails later, delete the moved file with
unlink().
❌ File Upload Don'ts
- Don't trust
$_FILES["type"]— It comes from the browser and can be spoofed. Always usefinfo. - Don't save files with their original names — Path traversal, collisions, and dangerous extensions are real risks.
- Don't allow PHP file uploads — Even with
.htaccess, if your allowlist includes.php,.phtml, or.phar, you have a remote code execution vulnerability. - Don't rely on file extensions alone — A file named
evil.jpgcould contain PHP code. Check the MIME type of the contents.
✅ Email Do's
- Validate all email inputs — Use
filter_var()withFILTER_VALIDATE_EMAILbefore including user input in email headers. - Hardcode the From address — Don't let users set the From header (this enables spam relay). Use Reply-To for their address.
- Use a library for production — PHPMailer supports SMTP authentication, attachments, HTML templates, and proper error handling.
❌ Email Don'ts
- Don't put user input directly in email headers — An attacker can inject newlines followed by
BCC: victim@example.comto turn your contact form into a spam relay. Always sanitize header values. - Don't use
mail()for production email — Deliverability will be poor without proper SMTP configuration, SPF records, and DKIM signatures. - Don't send sensitive data in email — Email is not encrypted in transit (usually). Never send passwords, tokens, or personal data in plain email.
PHP.ini Upload Settings
| Directive | Default | Description |
|---|---|---|
file_uploads |
On |
Enable/disable file uploads |
upload_max_filesize |
2M |
Max size of a single uploaded file |
post_max_size |
8M |
Max size of entire POST body (must be ≥ upload_max_filesize) |
max_file_uploads |
20 |
Max number of files per request |
upload_tmp_dir |
(system default) | Temp directory for uploads |
<?php
// Check current upload settings
echo "Max upload size: " . ini_get("upload_max_filesize") . "\n";
echo "Max POST size: " . ini_get("post_max_size") . "\n";
echo "Max files per request: " . ini_get("max_file_uploads") . "\n";
echo "Temp directory: " . (ini_get("upload_tmp_dir") ?: sys_get_temp_dir()) . "\n";
Summary
🎉 Key Takeaways
- enctype="multipart/form-data" is required on any form that uploads files — without it,
$_FILESwill be empty. - $_FILES provides five pieces of info per upload:
name,type,tmp_name,error, andsize. Always checkerrorfirst. - Validate with finfo, not $_FILES["type"] — the browser-reported MIME type can be spoofed.
finforeads actual file contents. - Generate safe filenames — Never use the original filename. Sanitize, add a random prefix, and limit length.
- move_uploaded_file() is the only safe way to move an uploaded file — it verifies the file was genuinely uploaded via POST.
- Multiple uploads use array notation (
name="files[]") and require reorganization of the$_FILESstructure. - mail() sends email through the local mail server — simple for learning, but use PHPMailer or a service for production.
- Hardcode the From header — Put user email in Reply-To only, never in From (prevents header injection / spam relay).
📚 Additional Resources
- PHP Manual: Handling File Uploads
- PHP Manual: $_FILES
- PHP Manual: move_uploaded_file()
- PHP Manual: mail()
- PHP Manual: finfo class
- PHPMailer on GitHub
- Mailpit — Local Email Testing
🚀 What's Next?
You now know how to accept files and send email — two essential features of any web application. But accepting user input always comes with risk. In Lesson 23: Security Fundamentals, we'll take a deep dive into the major web vulnerabilities: SQL injection, Cross-Site Scripting (XSS), and Cross-Site Request Forgery (CSRF). You'll learn how prepared statements, output escaping, and CSRF tokens protect your applications — and you'll see exactly what happens when they're missing.
🎉 Congratulations!
You've completed Lesson 22! You can now handle file uploads securely and send email from PHP. These skills connect directly to real-world features like profile pictures, document management, and contact forms.