First PHP Projects

Private File Upload Handler

public/upload.php
public/download.php
src/DocumentUpload.php
storage/documents/

The form uses method="post" and enctype="multipart/form-data". Authentication and CSRF run before file work.

Understand Rejection Before PHP Code

post_max_size can reject the whole request before the application sees fields, often leaving $_POST and $_FILES empty. upload_max_filesize produces UPLOAD_ERR_INI_SIZE for the file. Configure both server limits above the application's two-megabyte rule, then enforce the application rule again in PHP.

Map PHP's error codes to deliberate outcomes:

PHP example
<?php

declare(strict_types=1);

function uploadErrorMessage(int $error): string
{
    return match ($error) {
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is too large.',
        UPLOAD_ERR_PARTIAL => 'The upload was incomplete.',
        UPLOAD_ERR_NO_FILE => 'Choose a file to upload.',
        UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'Upload storage is unavailable.',
        UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload.',
        default => 'The upload failed.',
    };
}

Do not report a partial upload as “no file”; it indicates a different failure.

Validate Shape Before Values

A multiple-file field or crafted request can put arrays where scalar metadata is expected:

PHP example
// Partial: first step inside inspectDocumentUpload(array $file).
$error = $file['error'] ?? UPLOAD_ERR_NO_FILE;
if (!is_int($error)) {
    throw new InvalidArgumentException('The upload field has an invalid shape.');
}
if ($error !== UPLOAD_ERR_OK) {
    throw new InvalidArgumentException(uploadErrorMessage($error));
}

$size = $file['size'] ?? null;
$tmp = $file['tmp_name'] ?? null;
$name = $file['name'] ?? null;
if (!is_int($size) || !is_string($tmp) || !is_string($name)) {
    throw new InvalidArgumentException('The upload field has an invalid shape.');
}

Only after this check is it safe to pass $tmp to filesystem functions.

Check Size, Origin, And Detected Type

Keep the acceptance rule readable:

PHP example
// Partial: continues inspectDocumentUpload().
if ($size < 1 || $size > 2_000_000) {
    throw new InvalidArgumentException('The file must be between 1 byte and 2 MB.');
}
if (!is_uploaded_file($tmp)) {
    throw new InvalidArgumentException('The temporary file is not an HTTP upload.');
}

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
if (!is_string($mime) || !in_array($mime, ['application/pdf', 'image/png'], true)) {
    throw new InvalidArgumentException('Only PDF and PNG files are accepted.');
}

The original extension and browser-provided MIME type do not decide acceptance. is_uploaded_file() prevents this HTTP boundary from accepting an arbitrary local path.

Normalize the original name only for later display:

PHP example
// Partial: final metadata preparation.
$displayName = basename(str_replace('\\', '/', $name));
$displayName = preg_replace('/[\x00-\x1F\x7F]/', '', $displayName) ?? '';
if ($displayName === '') {
    $displayName = 'document';
}

return ['size' => $size, 'mime' => $mime, 'display_name' => $displayName];

Never use that name as the storage path.

Move To A Random Private Path

Generate an identifier, keep the file extensionless, and let move_uploaded_file() enforce upload origin again:

PHP example
// Partial: inside storeDocument().
$id = bin2hex(random_bytes(16));
$contentPath = $directory . '/' . $id . '.bin';
$metadataPath = $directory . '/' . $id . '.json';

if (!move_uploaded_file($file['tmp_name'], $contentPath)) {
    throw new RuntimeException('The uploaded file could not be stored.');
}

The directory must exist, be writable by PHP, and sit outside public/. Random identifiers reduce guessing but do not replace authorization.

Compensate When Metadata Fails

Filesystem and metadata writes are not one transaction. Persist owner ID, display name, detected MIME, and size. If metadata creation fails after the move, remove both partial artifacts:

PHP example
// Partial: metadata write after a successful move.
try {
    $json = json_encode($document, JSON_THROW_ON_ERROR);
    if (file_put_contents($metadataPath, $json, LOCK_EX) === false) {
        throw new RuntimeException('Document metadata could not be written.');
    }
} catch (Throwable $exception) {
    @unlink($metadataPath);
    @unlink($contentPath);
    throw $exception;
}

return $id;

The exercise solution uses exclusive metadata creation to avoid replacing an existing record. Simulate metadata failure and verify that no .bin remains.

Authorize Every Download

Accept only the identifier syntax your application generated:

PHP example
// Partial: start of public/download.php.
$id = $_GET['id'] ?? null;
$currentUserId = $_SESSION['user_id'] ?? null;

if (!is_string($id)
    || preg_match('/\A[a-f0-9]{32}\z/', $id) !== 1
    || !is_int($currentUserId)) {
    http_response_code(404);
    exit('Not Found');
}

Load the fixed .json metadata path, compare owner_id strictly, and verify the fixed .bin path exists. Return the same 404 for malformed, missing, and wrong-owner records so the route does not confirm another user's document.

Only after authorization, send controlled download headers:

PHP example
// Partial: final authorized download response.
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('X-Content-Type-Options: nosniff');
header("Content-Disposition: attachment; filename=\"download\"; filename*=UTF-8''" . rawurlencode($displayName));
readfile($contentPath);
exit;

Test valid, empty, oversized, partial, array-shaped, renamed executable, missing temporary path, metadata failure, malformed ID, wrong owner, and missing stored content.

Practice

Practice: Build A Private Document Upload

Implement the private upload and authorized download boundaries from the lesson.

Requirements

  • Use a multipart POST form protected by authentication and CSRF.
  • Explain how post_max_size and upload_max_filesize interact with the two-megabyte application limit.
  • Map every relevant UPLOAD_ERR_* value to a controlled outcome.
  • Reject array-shaped or incomplete $_FILES data without warnings.
  • Require a non-empty file and validate its server-detected MIME type.
  • Generate an extensionless random storage identifier in application code.
  • Keep content and metadata outside the public web root.
  • Persist display name, MIME type, size, and owner ID as metadata.
  • Remove content and metadata when either persistence step fails.
  • Accept only generated identifier syntax in the download route.
  • Return 404 for missing, malformed, or unauthorized records.
  • Send controlled download headers and stream only the stored path.

Test valid, rejected, partial-failure, wrong-owner, and missing-file paths. Confirm that no rejected request leaves a stored artifact.

Show solution
PHP example
<?php

declare(strict_types=1);

// no-execute: requires an authenticated multipart HTTP request and project storage.
$file = $_FILES['document'] ?? [];
if (!is_array($file)) {
    http_response_code(400);
    exit('Invalid upload field.');
}

try {
    $metadata = inspectDocumentUpload($file);
    $documentId = storeDocument(
        $file,
        $metadata,
        $_SESSION['user_id'],
        dirname(__DIR__) . '/storage/documents',
    );
} catch (InvalidArgumentException $exception) {
    http_response_code(422);
    exit($exception->getMessage());
} catch (RuntimeException) {
    http_response_code(500);
    exit('The document could not be stored.');
}

header('Location: /documents/' . $documentId, true, 303);
exit;

The form must contain enctype="multipart/form-data", a file input named document, and the session CSRF token. Reject a request whose authenticated user ID is absent or not an integer before calling storeDocument().

Use the complete download boundary from the lesson. Its ID allow-list, owner comparison, and fixed .bin suffix ensure request text never becomes an arbitrary filesystem path. Keep the original name only in the encoded download header.

For cleanup verification, temporarily make metadata creation fail after a successful move. The catch block inside storeDocument() must remove the .bin file. Also verify that another authenticated user receives the same 404 as a nonexistent document.