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.
<?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
../ 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.
<?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'];| Tool | Use |
|---|---|
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
Process CSV as a stream
<?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.
<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><?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
accept attribute are hints—not proof. Detect content server-side, generate a random name, and store outside the document root.Send controlled downloads
<?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
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?