Lesson 8 • Intermediate
File Handling 📂
By the end of this lesson you'll be able to read, write, and append files; stream large files line by line; handle CSV and JSON; work with directories; and do it all safely — the foundation of logging, exports, config, and data persistence in PHP.
What You'll Learn in This Lesson
- Write and read whole files in one line with file_put_contents() and file_get_contents()
- Open a handle with fopen() and pick the right mode: r (read), w (overwrite), a (append)
- Read a big file line by line with fgets() / feof(), or all at once with file()
- Check a file is there and usable with file_exists() and is_readable()
- Read and write CSV with fgetcsv() / fputcsv() and JSON with json_encode() / json_decode()
- List and create folders with scandir() and mkdir() — and avoid path-traversal bugs
php file.php. The Output panel under each example shows exactly what to expect.file_put_contents() drops a fresh document into a folder, replacing whatever was there. Appending adds a page to the end of an existing document. file_get_contents() pulls the whole document out to read at once. And a fopen() handle is keeping the drawer open so you can read or add one page at a time — just remember to slide the drawer shut (fclose()) when you're done.1️⃣ The Quick Pair: file_put_contents & file_get_contents
The fastest way to work with a file is the quick pair: two functions that each do a whole job in one line. file_put_contents($file, $text) creates the file (or overwrites it) and writes your string; file_get_contents($file) reads the whole file back as one string. Pass the flag FILE_APPEND to add to the end instead of wiping the file first. These open and close the file for you, so they're perfect for small files and simple jobs.
<?php
// The "quick" pair: two functions that do a whole job in one line each.
// Work inside the system temp directory so this runs anywhere safely.
$file = sys_get_temp_dir() . "/notes.txt";
// file_put_contents() CREATES the file (or OVERWRITES it if it exists)
// and writes the whole string in one go. It returns the number of bytes written.
$bytes = file_put_contents($file, "Buy milk\nWalk the dog\n");
echo "Wrote $bytes bytes.\n"; // Wrote 22 bytes.
// FILE_APPEND adds to the END instead of wiping the file first.
file_put_contents($file, "Pay rent\n", FILE_APPEND);
// file_get_contents() reads the WHOLE file back as one string.
$content = file_get_contents($file);
echo "--- file contents ---\n";
echo $content; // prints all three lines
?>Wrote 22 bytes.
--- file contents ---
Buy milk
Walk the dog
Pay rent2️⃣ Handles & Modes: fopen, fwrite, fclose
When you need to do several operations on one file, open a handle — a live connection to the file you keep open. fopen($file, $mode) returns the handle; fwrite() writes to it; and you must call fclose() when finished. The mode is the most important choice you'll make: "r" reads (the file must exist), "w" writes but truncates the file to empty first, and "a" appends to the end and never erases.
<?php
// The "detailed" way: open a HANDLE, do several operations, then close it.
// A handle (also called a stream) is a live connection to the file you keep open.
$file = sys_get_temp_dir() . "/log.txt";
// fopen() returns a handle. The MODE (second argument) decides what you may do:
// "r" read only — file must exist
// "w" write only — TRUNCATES (empties) the file, or creates it
// "a" append only — writes go to the END, never erases
$handle = fopen($file, "w"); // "w" => start with an empty file
fwrite($handle, "line one\n"); // write a string to the handle
fwrite($handle, "line two\n");
fclose($handle); // ALWAYS close when finished
// Re-open in "a" (append) mode so we keep the two lines above.
$handle = fopen($file, "a");
fwrite($handle, "line three\n");
fclose($handle);
echo file_get_contents($file); // three lines, in order
?>line one
line two
line three3️⃣ Reading Line by Line: fgets, feof & file()
For a large file, reading the whole thing at once can use a lot of memory. Instead, loop with fgets($handle), which returns the next line each call and false when there are no more — so while (($line = fgets($handle)) !== false) walks the file one line at a time. feof() ("end of file") tells you when you've read past the last line. If the file is small and you just want every line, file($file) returns them as an array in one call.
<?php
// Reading a big file line by line keeps memory low — you hold ONE line at a time,
// not the whole file. This is the right tool for large logs or data dumps.
$file = sys_get_temp_dir() . "/cities.txt";
file_put_contents($file, "London\nParis\nTokyo\n");
// Open for reading. fgets() returns the NEXT line (including its \n), or false at the end.
$handle = fopen($file, "r");
$n = 1;
while (($line = fgets($handle)) !== false) { // loop until fgets() returns false
echo $n . ": " . trim($line) . "\n"; // trim() removes the trailing newline
$n++;
}
fclose($handle);
// feof() means "end of file" — true once you've read past the last line.
echo "Reached end of file: " . var_export(feof($handle = fopen($file, "r")), true) . "\n";
fclose($handle);
// Shortcut: file() reads the WHOLE file into an array, one line per element.
$lines = file($file, FILE_IGNORE_NEW_LINES); // flag drops the trailing \n on each line
echo "file() gave " . count($lines) . " lines; the 2nd is '" . $lines[1] . "'.\n";
?>1: London
2: Paris
3: Tokyo
Reached end of file: false
file() gave 3 lines; the 2nd is 'Paris'.4️⃣ Check Before You Touch: file_exists & is_readable
Reading a file that isn't there throws a warning and can break your page. Defend against it: file_exists($file) asks "is anything at this path?", is_readable($file) asks "am I allowed to read it?", and is_writable($file) asks the same about writing. Checking first turns a crash into a message you control. You can also inspect a path with filesize(), pathinfo(), and basename().
<?php
// NEVER assume a file is there. Check before you read so you fail gracefully
// instead of crashing with a warning.
$file = sys_get_temp_dir() . "/maybe.txt";
if (!file_exists($file)) {
echo "No file yet — creating one.\n";
file_put_contents($file, "now it exists");
}
// file_exists() = is there anything at this path?
// is_readable() = am I allowed to read it?
// is_writable() = am I allowed to write to it?
if (file_exists($file) && is_readable($file)) {
echo "Safe to read: " . file_get_contents($file) . "\n";
} else {
echo "Cannot read the file.\n";
}
// Handy facts about the path (these work even on names, no file needed):
echo "Size: " . filesize($file) . " bytes\n";
echo "Extension: " . pathinfo($file, PATHINFO_EXTENSION) . "\n"; // txt
echo "Basename: " . basename($file) . "\n"; // maybe.txt
?>No file yet — creating one.
Safe to read: now it exists
Size: 13 bytes
Extension: txt
Basename: maybe.txt5️⃣ CSV Files: fgetcsv & fputcsv
CSV (comma-separated values) is the format spreadsheets and exports speak. Don't build CSV by gluing strings with commas — a value that contains a comma would break the columns. Instead, fputcsv($handle, $array) writes one array as a correctly-quoted CSV line, and fgetcsv($handle) reads one line back into an array, respecting quotes. Notice in the output that Bob's comma-containing email stays a single field.
<?php
// CSV (comma-separated values) is the universal spreadsheet/export format.
// Use fputcsv()/fgetcsv() instead of joining strings yourself — they handle
// commas, quotes and escaping inside a value correctly.
$file = sys_get_temp_dir() . "/people.csv";
$rows = [
["Name", "Email", "Age"], // header row
["Alice", "alice@example.com", 28],
["Bob", "bob, jr@example.com", 34], // note the comma INSIDE the email
];
// WRITE the CSV.
$out = fopen($file, "w");
foreach ($rows as $row) {
fputcsv($out, $row); // one array => one CSV line
}
fclose($out);
// READ it back. fgetcsv() turns each line into an array, splitting on commas
// but respecting quotes — so "bob, jr@example.com" stays a single field.
$in = fopen($file, "r");
$headers = fgetcsv($in); // first line = column names
echo implode(" | ", $headers) . "\n";
while (($data = fgetcsv($in)) !== false) {
echo $data[0] . " is " . $data[2] . "\n"; // Name is Age
}
fclose($in);
?>Name | Email | Age
Alice is 28
Bob is 34Now you try. This script should build a log that grows — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — build a tiny log that GROWS instead of being overwritten.
// Fill in each blank marked ___ using the 👉 hint, then run it.
$file = sys_get_temp_dir() . "/visits.log";
// Start clean so the demo is repeatable.
file_put_contents($file, "");
// 1) Append three lines. The flag that adds to the END (not overwrite) is FILE_APPEND.
file_put_contents($file, "Visit 1\n", ___); // 👉 the append flag
file_put_contents($file, "Visit 2\n", ___); // 👉 same flag again
file_put_contents($file, "Visit 3\n", ___); // 👉 and again
// 2) Read the whole log back as one string.
echo ___; // 👉 the "quick read" function: ___($file)
// ✅ Expected output:
// Visit 1
// Visit 2
// Visit 3
?>Visit 1
Visit 2
Visit 3FILE_APPEND and the read with file_get_contents($file), then run it. You should see all three visits.6️⃣ JSON Files: json_encode & json_decode
JSON is how APIs and config files store structured data. json_encode($array) turns an array into a JSON string you can save (add JSON_PRETTY_PRINT to indent it for humans), and json_decode($string, true) turns it back into an associative array. Pair them with the quick read/write functions and you have a tiny database in a file.
<?php
// JSON is the format APIs and config files speak. PHP converts between an
// array/object and a JSON string with one function each.
$file = sys_get_temp_dir() . "/config.json";
$settings = [
"site" => "My Blog",
"admin" => "alice@example.com",
"tags" => ["php", "files", "json"],
];
// json_encode() turns the array into a JSON string. JSON_PRETTY_PRINT indents it
// so a human can read the file.
file_put_contents($file, json_encode($settings, JSON_PRETTY_PRINT));
// json_decode($s, true) turns the JSON string back into an associative array.
// (Without the 'true' you'd get an object instead.)
$loaded = json_decode(file_get_contents($file), true);
echo "Site: " . $loaded["site"] . "\n";
echo "Tags: " . implode(", ", $loaded["tags"]) . "\n";
?>Site: My Blog
Tags: php, files, jsonOne more guided exercise. Number every line of a file by reading it one line at a time. Replace each ___ using the hints.
<?php
// 🎯 YOUR TURN — number every line of a file.
// Fill in the blanks so the loop reads one line at a time until the end.
$file = sys_get_temp_dir() . "/fruit.txt";
file_put_contents($file, "apple\nbanana\ncherry\n");
// 1) Open the file for READING (which one-letter mode is read-only?).
$handle = fopen($file, "___"); // 👉 the read mode
$n = 1;
// 2) fgets() returns the next line, or false at the end. Loop while it is NOT false.
while (($line = ___($handle)) !== false) { // 👉 the line-reading function
echo $n . ": " . trim($line) . "\n"; // trim() drops the newline
$n++;
}
// 3) Close the handle when you're done.
___($handle); // 👉 the closing function
// ✅ Expected output:
// 1: apple
// 2: banana
// 3: cherry
?>1: apple
2: banana
3: cherry"r", the line reader is fgets, and the closer is fclose. Run it to see three numbered lines.7️⃣ Directories: scandir & mkdir
Folders have their own small toolkit. mkdir($dir, 0755, true) creates a directory — the true also creates any missing parent folders, and 0755 sets permissions. is_dir() checks a folder exists. scandir($dir) lists everything inside — but it always includes "." (this folder) and ".." (the parent), so filter those out before you use the list.
<?php
// Directories (folders) have their own small toolkit.
$dir = sys_get_temp_dir() . "/php_demo";
// mkdir() creates a folder. The 'true' makes it create missing parents too,
// and 0755 is the permission setting (owner can write, others can read/run).
if (!is_dir($dir)) { // is_dir() => does this folder exist?
mkdir($dir, 0755, true);
}
// Put a couple of files inside so there's something to list.
file_put_contents("$dir/a.txt", "first");
file_put_contents("$dir/b.txt", "second");
// scandir() returns EVERY entry in the folder — including "." (here) and
// ".." (parent). Filter those two out before you use the list.
$entries = array_diff(scandir($dir), [".", ".."]);
echo "Files in folder:\n";
foreach ($entries as $name) {
echo " - $name\n";
}
?>Files in folder:
- a.txt
- b.txtSafety: never trust a user-supplied path
If you build a file path from user input — say a ?file= value in the URL — an attacker can send something like ../../etc/passwd to climb out of your folder and read system files. This is a path traversal attack, and it's one of the most common file-handling bugs.
Defend in three layers: strip the directory part with basename($input) so only a filename survives; keep everything inside a fixed base folder; and validate the name against an allow-list before opening it.
<?php
$base = sys_get_temp_dir() . "/uploads";
$user = "../../etc/passwd"; // ⚠️ malicious input
// basename() throws away any directory part — only the final name survives.
$safe = basename($user); // "passwd" (the .. climbing is gone)
// Only allow simple names made of letters, numbers, dash, underscore and dot.
if (!preg_match('/^[\\w.\\-]+$/', $safe)) {
exit("Invalid filename.\n");
}
$path = "$base/$safe"; // always inside our base folder
echo "Will read: $path\n"; // Will read: .../uploads/passwd
?>Will read: /tmp/uploads/passwdEven though the name "passwd" survives, it's now safely pinned inside your uploads folder — the dangerous ../ parts are stripped, so it can never reach the real system file.
Common Errors (and the fix)
- Your file is suddenly empty after you "added" to it — you opened it with mode
"w", which truncates the file to empty the instant it opens. To add without erasing, use"a"(append) orfile_put_contents($f, $text, FILE_APPEND). - "failed to open stream: No such file or directory" — the path is wrong or the folder doesn't exist yet. Check for a typo, and create the directory with
mkdir($dir, 0755, true)before writing into it. Guard reads withfile_exists()first. - "failed to open stream: Permission denied" — the account PHP runs as isn't allowed to read or write there. Confirm with
is_writable($dir), and make the target folder writable by the server (or write tosys_get_temp_dir(), which always is). - Writes seem to vanish, or the file is locked by another process — you forgot
fclose($handle), so buffered data was never flushed and the handle stayed open. Always close a handle when you're done; the quick functions close for you. - A user reads files they shouldn't — you built the path from raw input, allowing
../path traversal. Run the name throughbasename()and an allow-list, and keep it inside a fixed base folder.
Pro Tips
- 💡 For log files, append with a lock:
file_put_contents($f, $msg . PHP_EOL, FILE_APPEND | LOCK_EX)—LOCK_EXstops two simultaneous writes from corrupting each other. - 💡 Use
PHP_EOLfor line endings instead of hard-coding\nwhen a file may be read on Windows — it picks the right newline for the platform. - 💡 Small files only? Prefer the quick pair; reach for
fopen()handles when files get large or you need to stream them line by line.
📋 Quick Reference — File Handling
| Function / Mode | Example | What It Does |
|---|---|---|
| file_put_contents() | file_put_contents($f, $s) | Write a string (overwrites) |
| FILE_APPEND | ..., $s, FILE_APPEND) | Add to the end, don't erase |
| file_get_contents() | $s = file_get_contents($f) | Read whole file as a string |
| file() | $lines = file($f) | Read file into an array of lines |
| fopen() / fclose() | $h = fopen($f, "r") | Open / close a file handle |
| fgets() / fwrite() | fgets($h) | Read / write one line at a time |
| "r" / "w" / "a" | fopen($f, "a") | Read / overwrite / append mode |
| fgetcsv() / fputcsv() | $row = fgetcsv($h) | Read / write one CSV line |
| json_encode/decode() | json_decode($s, true) | Array ⇄ JSON string |
| file_exists() | file_exists($f) | Does the path exist? |
| scandir() / mkdir() | scandir($dir) | List / create directories |
Frequently Asked Questions
Q: When should I use file_get_contents() versus fopen()?
Reach for file_get_contents()/file_put_contents() for small files and simple jobs — they read or write the whole thing in a single line and close the file for you. Use fopen() with fgets()/fwrite() when the file is large or you want to stream it line by line, because a handle lets you process one line at a time instead of loading megabytes into memory at once. The rule of thumb: one-shot and small, use the quick pair; big or streaming, open a handle.
Q: What is the difference between the 'w' and 'a' file modes?
Both let you write, but 'w' truncates the file the moment you open it — every existing line is wiped, and you start from an empty file. 'a' (append) never erases anything: every write goes to the end of the file, which is exactly what you want for logs. If you open with 'w' expecting your old data to still be there, it will already be gone, so choose the mode deliberately.
Q: Do I really have to call fclose()?
PHP closes any open handles automatically when the script ends, so a quick script often works without it. But you should still call fclose() yourself: it flushes buffered writes to disk immediately, releases the file so other code (or other users) can open it, and frees the resource in long-running scripts where leaks add up. The quick functions like file_put_contents() handle closing internally, which is one reason they're convenient for simple cases.
Q: I get 'Permission denied' or 'failed to open stream'. Why?
That warning means PHP could not open the path — usually the file or directory doesn't exist, or the web server's user account isn't allowed to read/write there. Check the path is correct (a typo points at a missing file), confirm the folder exists (create it with mkdir first), and make sure the directory is writable by the server. Calling file_exists() and is_writable() before you read or write turns a fatal-looking warning into a message you can handle.
Q: How do I stop users from reading files they shouldn't (path traversal)?
Never build a file path by gluing raw user input straight into it. A value like "../../etc/passwd" can climb out of your folder and reach system files — that's a path traversal attack. Strip the directory part with basename(), keep files inside a fixed base folder, and validate the name against an allow-list (for example, only letters, numbers and a known extension) before opening anything.
Mini-Challenge: A High-Score Table in JSON
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This save-then-load loop is exactly how real apps persist data between runs.
<?php
// 🎯 MINI-CHALLENGE: A high-score table backed by a JSON file.
// No code is filled in — work from the steps below, then run it.
//
// Use this path: $file = sys_get_temp_dir() . "/scores.json";
//
// 1. Build an array of scores, e.g.
// $scores = [ ["name" => "Ada", "score" => 90], ["name" => "Sam", "score" => 75] ];
// 2. Save it to $file as pretty JSON (json_encode + file_put_contents, JSON_PRETTY_PRINT).
// 3. Load it back into an array (file_get_contents + json_decode with true).
// 4. Loop the loaded array and echo: "<name>: <score>" on its own line.
// 5. Bonus: before saving, check is_writable() on the temp dir.
//
// ✅ Expected output (with the example data above):
// Ada: 90
// Sam: 75
// your code here
?>🎉 Lesson Complete!
- ✅ The quick pair —
file_put_contents()andfile_get_contents()— write and read a whole file in one line; addFILE_APPENDto grow it - ✅ A
fopen()handle +fwrite()/fgets()let you stream, and you always finish withfclose() - ✅ Modes matter:
"r"reads,"w"overwrites,"a"appends - ✅ Read line by line with
fgets()/feof(), or all at once withfile() - ✅ Use
fgetcsv()/fputcsv()for CSV andjson_encode()/json_decode()for JSON - ✅ Guard with
file_exists()/is_readable(), and never trust a user-supplied path — sanitise withbasename() - ✅ Next lesson: Forms & User Input — receive data from the browser and validate it safely
Sign up for free to track which lessons you've completed and get learning reminders.