Files and Directories
PHP has simple functions for whole files — read it all, write it all — and lower-level stream functions for processing large files piece by piece. This lesson covers both, plus CSV files, paths, directories, and locking a file so two requests don't write to it at once.
Reading and writing whole files
file_put_contents writes a string to a file (creating or replacing it) and returns the number of bytes written. file_get_contents reads a whole file into a string, and file reads it into an array of lines:
<?php $bytes = file_put_contents("notes.txt", "Buy milk\nCall the bank\n"); echo "Wrote $bytes bytes\n"; file_put_contents("notes.txt", "Book dentist\n", FILE_APPEND); echo file_get_contents("notes.txt"); $lines = file("notes.txt", FILE_IGNORE_NEW_LINES); echo count($lines), " lines, last: {$lines[2]}\n";
Wrote 23 bytes Buy milk Call the bank Book dentist 3 lines, last: Book dentist
Relative paths are resolved from the current working directory, which for web requests is usually the script's folder but isn't guaranteed. Build paths from __DIR__ to be safe: __DIR__ . '/data/notes.txt'.
Checking and handling errors
File functions return false and emit a warning on failure. Check before reading where it makes sense, and always check the return value:
<?php $path = "config.ini"; if (!is_file($path)) { echo "No $path — using defaults\n"; } $content = @file_get_contents($path); if ($content === false) { echo "Could not read $path: ", error_get_last()['message'] ?? 'unknown error', "\n"; } file_put_contents("data.txt", "12345"); var_dump(file_exists("data.txt"), is_readable("data.txt"), filesize("data.txt"));
No config.ini — using defaults Could not read config.ini: file_get_contents(config.ini): Failed to open stream: No such file or directory bool(true) bool(true) int(5)
Here the @ suppresses the warning because the code handles the failure itself and reports it with error_get_last(). That's one of the few legitimate uses of @.
Streams: reading line by line
For big files, open a handle with fopen and read a line at a time with fgets. Memory use stays tiny however big the file is:
<?php $out = fopen("server.log", "w"); for ($i = 1; $i <= 1000; $i++) { fwrite($out, $i % 250 === 0 ? "$i ERROR disk slow\n" : "$i INFO ok\n"); } fclose($out); $in = fopen("server.log", "r"); $errors = 0; $lineNo = 0; while (($line = fgets($in)) !== false) { $lineNo++; if (str_contains($line, "ERROR")) { $errors++; $first ??= "line $lineNo: " . trim($line); } } fclose($in); echo "$errors errors, first on $first\n";
4 errors, first on line 250: 250 ERROR disk slow
| Mode | Meaning |
|---|---|
r | Read only; the file must exist |
w | Write; creates the file or empties an existing one |
a | Append to the end; creates the file if needed |
x | Create a new file; fails if it already exists |
r+, w+, a+ | Read and write |
The SplFileObject class wraps the same operations in an object you can loop over with foreach.
CSV files
fgetcsv and fputcsv read and write comma-separated values correctly, including quoted fields that contain commas:
region,product,units,price
North,Widget,12,2.50
South,"Gadget, deluxe",4,10.00
North,Gadget,7,10.00
East,Widget,20,2.50
<?php $handle = fopen("sales.csv", "r"); $header = fgetcsv($handle, escape: ""); $totals = []; while (($row = fgetcsv($handle, escape: "")) !== false) { $record = array_combine($header, $row); $totals[$record['region']] = ($totals[$record['region']] ?? 0) + $record['units'] * $record['price']; } fclose($handle); ksort($totals); foreach ($totals as $region => $revenue) { printf("%-6s %7.2f\n", $region, $revenue); } $out = fopen("summary.csv", "w"); fputcsv($out, ["region", "revenue"], escape: ""); foreach ($totals as $region => $revenue) { fputcsv($out, [$region, number_format($revenue, 2, '.', '')], escape: ""); } fclose($out); echo file_get_contents("summary.csv");
East 50.00 North 100.00 South 40.00 region,revenue East,50.00 North,100.00 South,40.00
array_combine($header, $row) turns each row into an associative array keyed by the column names. Passing escape: "" disables PHP's old non-standard escape character, so files follow the CSV standard (and avoids a deprecation notice in PHP 8.4+).
Paths
<?php $path = "/var/www/uploads/2026/report.final.pdf"; echo basename($path), "\n"; echo dirname($path), "\n"; print_r(pathinfo($path));
report.final.pdf
/var/www/uploads/2026
Array
(
[dirname] => /var/www/uploads/2026
[basename] => report.final.pdf
[extension] => pdf
[filename] => report.final
)Forward slashes work as path separators on Windows too. Never build a path from user input without checking it: a filename like ../../config.php could escape your folder (a path traversal attack). Use basename() on untrusted names, or better, look files up by ID.
Directories
<?php mkdir("photos/2026/summer", recursive: true); foreach (["beach.jpg", "sunset.jpg", "notes.txt"] as $name) { touch("photos/2026/summer/$name"); } var_dump(is_dir("photos")); print_r(glob("photos/2026/summer/*.jpg")); print_r(array_values(array_diff(scandir("photos/2026/summer"), [".", ".."]))); $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("photos", FilesystemIterator::SKIP_DOTS)); echo iterator_count($files), " files in total\n"; array_map(unlink(...), glob("photos/2026/summer/*")); rmdir("photos/2026/summer"); var_dump(is_dir("photos/2026/summer"));
bool(true)
Array
(
[0] => photos/2026/summer/beach.jpg
[1] => photos/2026/summer/sunset.jpg
)
Array
(
[0] => beach.jpg
[1] => notes.txt
[2] => sunset.jpg
)
3 files in total
bool(false)rmdir only removes empty directories, so the files are deleted first with unlink.
Locking
On a web server, two requests can run at the same moment. If both append to the same file, their writes can interleave. flock takes an exclusive lock so only one writes at a time; LOCK_EX does the same for file_put_contents:
<?php function incrementCounter(string $file): int { $handle = fopen($file, "c+"); // create if missing, don't truncate flock($handle, LOCK_EX); // wait for exclusive access $count = (int) stream_get_contents($handle) + 1; ftruncate($handle, 0); rewind($handle); fwrite($handle, (string) $count); flock($handle, LOCK_UN); fclose($handle); return $count; } incrementCounter("visits.txt"); incrementCounter("visits.txt"); echo incrementCounter("visits.txt"), "\n"; file_put_contents("audit.log", "user 7 logged in\n", FILE_APPEND | LOCK_EX);
3
For anything beyond simple logs and counters, a database (lesson 57) handles concurrent access for you.
Temporary files
tempnam(sys_get_temp_dir(), 'prefix') creates a uniquely named temporary file, and tmpfile() opens one that's deleted automatically when closed. Use them for intermediate data such as generated exports.
Try it yourself
Write a few journal entries to a file, one per line, then read them back and print only the entries from March.
Mar 2: Finished the arrays lessons Mar 15: Built my first class