First PHP Projects

Private File Upload Handler

Uploads cross the HTTP, PHP configuration, temporary-file, filesystem, metadata, and authorization boundaries. Build a private document store whose files cannot be fetched directly from the web root.

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

The upload form must use method="post" and enctype="multipart/form-data". Protect the POST with the session-backed CSRF pattern from the contact-form project.

Align PHP And Application Limits

PHP rejects requests before application code when post_max_size is exceeded, often leaving both $_POST and $_FILES empty. A per-file upload_max_filesize failure appears as UPLOAD_ERR_INI_SIZE. Set the server limits above the application's two-megabyte rule, restart the relevant PHP SAPI after configuration changes, and still enforce the application limit in code.

Do not treat every upload failure as “no file.” Map PHP's error code deliberately.

Validate The Complete Upload Shape

Put the reusable boundary in src/DocumentUpload.php:

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 => 'The server upload directory is unavailable.',
        UPLOAD_ERR_CANT_WRITE => 'The server could not write the upload.',
        UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload.',
        UPLOAD_ERR_OK => 'The upload succeeded.',
        default => 'The upload failed for an unknown reason.',
    };
}

function inspectDocumentUpload(array $file): array
{
    $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;
    $temporaryPath = $file['tmp_name'] ?? null;
    $originalName = $file['name'] ?? null;
    if (!is_int($size) || !is_string($temporaryPath) || !is_string($originalName)) {
        throw new InvalidArgumentException('The upload field has an invalid shape.');
    }
    if ($size < 1 || $size > 2_000_000) {
        throw new InvalidArgumentException('The file must be between 1 byte and 2 MB.');
    }
    if (!is_uploaded_file($temporaryPath)) {
        throw new InvalidArgumentException('The temporary file is not an HTTP upload.');
    }

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

    $displayName = basename(str_replace('\\', '/', $originalName));
    $displayName = preg_replace('/[\x00-\x1F\x7F]/', '', $displayName) ?? '';
    if ($displayName === '') {
        $displayName = 'document';
    }

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

The browser-provided MIME type and extension are not used for acceptance. finfo examines the temporary file, and is_uploaded_file() prevents this boundary from accepting an arbitrary local path.

Move The File And Persist Metadata

Store extensionless content and sidecar metadata under a random identifier. If metadata persistence fails after the move, remove both partial artifacts:

PHP example
<?php

declare(strict_types=1);

function storeDocument(array $file, array $metadata, int $ownerId, string $directory): string
{
    if (!is_dir($directory) || !is_writable($directory)) {
        throw new RuntimeException('Private storage is not writable.');
    }

    $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.');
    }

    try {
        $document = [
            'id' => $id,
            'owner_id' => $ownerId,
            'display_name' => $metadata['display_name'],
            'mime' => $metadata['mime'],
            'size' => $metadata['size'],
        ];
        $json = json_encode($document, JSON_THROW_ON_ERROR);
        $handle = fopen($metadataPath, 'x');
        if ($handle === false) {
            throw new RuntimeException('Document metadata could not be created.');
        }

        try {
            if (fwrite($handle, $json) !== strlen($json) || !fflush($handle)) {
                throw new RuntimeException('Document metadata could not be written.');
            }
        } finally {
            fclose($handle);
        }
    } catch (Throwable $exception) {
        @unlink($metadataPath);
        @unlink($contentPath);
        throw $exception;
    }

    return $id;
}

Call inspectDocumentUpload($_FILES['document'] ?? []) before storeDocument(). Obtain $ownerId from authenticated server-side session state, not from a hidden form field. Check CSRF before inspecting or moving the file.

The random identifier makes guessing difficult, but randomness is not authorization. Every download must still load metadata and compare the stored owner with the current user.

Stream Through An Authorized Download

The download route accepts only the identifier format generated above. It never joins an arbitrary request path onto the storage directory:

PHP example
<?php

declare(strict_types=1);

// no-execute: requires authenticated session state and a requested document ID.
$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');
}

$directory = dirname(__DIR__) . '/storage/documents';
$metadataPath = $directory . '/' . $id . '.json';
$contentPath = $directory . '/' . $id . '.bin';

try {
    $metadata = json_decode(file_get_contents($metadataPath), true, flags: JSON_THROW_ON_ERROR);
} catch (Throwable) {
    http_response_code(404);
    exit('Not Found');
}

if (!is_array($metadata)
    || ($metadata['owner_id'] ?? null) !== $currentUserId
    || !is_file($contentPath)) {
    http_response_code(404);
    exit('Not Found');
}

$size = filesize($contentPath);
if ($size === false) {
    http_response_code(500);
    exit('Download unavailable.');
}

$displayName = is_string($metadata['display_name'] ?? null)
    ? $metadata['display_name']
    : 'document';
$mime = is_string($metadata['mime'] ?? null)
    ? $metadata['mime']
    : 'application/octet-stream';

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;

Returning the same 404 for an invalid ID, missing record, and wrong owner avoids confirming that another user's document exists. Content-Disposition: attachment and nosniff reduce the chance that uploaded content is interpreted inline by a browser.

Verify Failure And Cleanup Paths

Test a valid PDF, empty file, oversized file, partial-upload error, array-shaped field, renamed executable, missing temporary path, wrong owner, malformed ID, missing stored content, and simulated metadata-write failure. The failure case must leave no .bin or .json artifact.

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.