Skip to main content

📎 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 $_FILES superglobal 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.

sequenceDiagram participant U as User (Browser) participant S as Server (PHP) participant T as Temp Directory participant D as Uploads Directory U->>S: POST form with file (multipart/form-data) S->>T: PHP saves file to temp location S->>S: Populate $_FILES with file info S->>S: Validate type, size, errors alt Valid file S->>D: move_uploaded_file() to permanent path S->>U: Success response else Invalid file S->>T: Temp file auto-deleted S->>U: Error response end

There are two essential requirements for a file upload form:

  1. The form method must be POST (files can't be sent via GET)
  2. 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:

  1. Error code — Was the upload itself successful?
  2. File type — Is it an allowed MIME type? (Verify on the server, not just the browser's claim)
  3. 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 .htaccess to 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 the uploadErrorMessage(), validateUpload(), reorganizeFiles(), and safeFilename() 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-To header uses the validated email, but the From header 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:

  1. Accept only JPEG and PNG files
  2. Maximum file size: 2 MB
  3. Generate a unique filename using safeFilename()
  4. Display the uploaded image after a successful upload
  5. 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:

  1. Add a file input to the contact form (don't forget enctype!)
  2. Validate the attachment if one is provided (type + size)
  3. Save the attachment to an uploads directory
  4. Include the attachment path in the email body so the admin knows where to find it
  5. 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:

  1. Accept only .csv files (MIME type text/csv or text/plain)
  2. Read the file with fgetcsv() after moving it to the uploads directory
  3. Use the first row as table headers
  4. Display the data in a styled HTML table
  5. 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 accept attributes are hints, not security. Validate MIME type with finfo, check size, and verify with is_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 .htaccess to disable the PHP engine, or better yet, store uploads outside the document root.
  • Set php.ini limits — Configure upload_max_filesize and post_max_size to match your application's needs. post_max_size must be larger than upload_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 use finfo.
  • 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.jpg could contain PHP code. Check the MIME type of the contents.

✅ Email Do's

  • Validate all email inputs — Use filter_var() with FILTER_VALIDATE_EMAIL before 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.com to 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, $_FILES will be empty.
  • $_FILES provides five pieces of info per upload: name, type, tmp_name, error, and size. Always check error first.
  • Validate with finfo, not $_FILES["type"] — the browser-reported MIME type can be spoofed. finfo reads 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 $_FILES structure.
  • 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

🚀 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.