Phase 2 · Web FundamentalsModule 12~50 min read

Files, Uploads & Downloads

Read and write files, persist JSON and CSV, and handle uploads and downloads with strict safety checks.

What you'll learn

Files are useful boundaries for configuration, exports, imports, logs, and uploads—but paths and client-supplied files demand deliberate safety checks.

  • Build paths relative to source files
  • Read and write text, JSON, and CSV with failure handling
  • Validate upload errors, size, and detected media type
  • Generate filenames rather than trusting client names
  • Stream downloads with correct headers
  • Know when file storage stops being enough

Build paths you control

The current working directory can change between the CLI, development server, tests, and production workers. Anchor application paths with __DIR__ or a configured project root.

paths.php
<?php

$projectRoot = dirname(__DIR__);
$configPath = $projectRoot . '/config/app.php';
$logPath = $projectRoot . '/storage/logs/app.log';

echo $configPath . PHP_EOL;
echo basename($logPath);

Prevent path traversal

Never append an unchecked request value to a filesystem path. Values such as ../ can escape the intended directory. Map public identifiers to server-owned paths or strictly allowlist filenames.

Text and JSON persistence

Small local tools can persist structured data as JSON. Check every operation that can fail, lock writes, and use throwing JSON errors instead of silently accepting malformed data.

json-store.php
<?php

declare(strict_types=1);

$storage = dirname(__DIR__) . '/storage';
$path = $storage . '/courses.json';

if (!is_dir($storage) && !mkdir($storage, 0770, true)) {
    throw new RuntimeException('Could not create storage directory.');
}

$courses = [
    ['id' => 1, 'title' => 'PHP Foundations'],
    ['id' => 2, 'title' => 'Web Fundamentals'],
];

$json = json_encode($courses, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

if (file_put_contents($path, $json, LOCK_EX) === false) {
    throw new RuntimeException('Could not write course data.');
}

$loaded = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
echo $loaded[1]['title'];
ToolUse
file_get_contents()Read an entire small file
file_put_contents()Write an entire string, optionally with LOCK_EX
fopen()Open a stream for incremental processing
fgets() / fread()Read part of a stream
fwrite()Write to a stream
fclose()Release the handle deterministically

Note

A lock prevents cooperating PHP processes from writing simultaneously; it does not provide database transactions, queries, indexes, replication, or recovery guarantees.

Process CSV as a stream

import-courses.php
<?php

declare(strict_types=1);

$handle = fopen(__DIR__ . '/courses.csv', 'rb');

if ($handle === false) {
    throw new RuntimeException('Could not open CSV file.');
}

try {
    $header = fgetcsv($handle, escape: '');

    while (($row = fgetcsv($handle, escape: '')) !== false) {
        if (count($row) !== count($header)) {
            continue;
        }

        $course = array_combine($header, $row);
        echo $course['title'] . PHP_EOL;
    }
} finally {
    fclose($handle);
}

Streaming keeps memory bounded for large imports. Validate the header, row width, encoding, and every field before changing application state.

Treat uploads as hostile input

The upload form must use method="post" and enctype="multipart/form-data". On the server, check PHP's upload error first, then server-side size and content rules.

avatar-form.html
<form method="post" enctype="multipart/form-data">
  <label for="avatar">Profile image (JPEG or PNG, max 2 MB)</label>
  <input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png" required>
  <button>Upload</button>
</form>
upload-avatar.php
<?php

declare(strict_types=1);

$file = $_FILES['avatar'] ?? null;

if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    throw new RuntimeException('A valid upload is required.');
}

if (($file['size'] ?? 0) > 2 * 1024 * 1024) {
    throw new RuntimeException('The file must be 2 MB or smaller.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$extensions = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
];

if (!isset($extensions[$mime])) {
    throw new RuntimeException('Only JPEG and PNG images are allowed.');
}

$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
$destination = dirname(__DIR__) . '/storage/uploads/' . $filename;

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

Key idea

The original filename, extension, browser content type, and accept attribute are hints—not proof. Detect content server-side, generate a random name, and store outside the document root.

Send controlled downloads

download.php
<?php

declare(strict_types=1);

$exports = [
    'monthly-report' => dirname(__DIR__) . '/storage/exports/monthly.csv',
];

$id = (string) ($_GET['id'] ?? '');
$path = $exports[$id] ?? null;

if ($path === null || !is_file($path)) {
    http_response_code(404);
    exit('File not found.');
}

header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="monthly-report.csv"');
header('Content-Length: ' . filesize($path));

readfile($path);

Watch out

Authorize the current user before revealing a private download, and map an opaque identifier to a server-owned path. Never accept an arbitrary filesystem path from the URL.

Know when to move beyond local files

  • Use a database for concurrent structured records and queries
  • Use object storage for durable user uploads across multiple app instances
  • Use a queue for expensive parsing, image processing, or virus scanning
  • Use temporary files for bounded intermediate work and delete them reliably
  • Back up durable storage and test restoration—not only backup creation

Recap & quick check

Key takeaways

  • Anchor application paths to __DIR__ or a configured project root.
  • Check file operations and JSON decoding instead of ignoring failure returns.
  • Stream large CSV files rather than loading them fully into memory.
  • For uploads, verify error, size, detected content, generated name, and controlled storage location.
  • Authorize downloads and map public IDs to server-owned paths.

Quick check

1. Why prefer __DIR__ when constructing application paths?

2. Which upload property should not be trusted as proof of type?

3. Why generate a random upload filename?

4. What is best for a very large CSV import?