File Handling

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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
?>

2️⃣ 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
?>

3️⃣ 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";
?>

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";
@unlink($file);   // start clean, so this demo always takes the "create" path

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
?>

5️⃣ 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", "[email protected]", 28],
    ["Bob", "bob, [email protected]", 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, [email protected]" 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);
?>

Now 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
?>

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" => "[email protected]",
    "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";
?>

One 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
?>

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";
}
?>

Safety: 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
?>

Even 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)

Pro Tips

📋 Quick Reference — File Handling

Function / ModeExampleWhat 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

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!

Practice quiz

What does file_put_contents($file, $text) do if the file already exists?

  • Appends to the end
  • Throws an error
  • Overwrites the whole file
  • Does nothing

Answer: Overwrites the whole file. file_put_contents creates the file or overwrites it. Pass the FILE_APPEND flag to add to the end instead.

Which flag makes file_put_contents add to the end instead of overwriting?

  • FILE_APPEND
  • FILE_ADD
  • FILE_END
  • APPEND_MODE

Answer: FILE_APPEND. Pass FILE_APPEND as the third argument to write to the end of the file without erasing it first.

What happens to a file the moment you open it with fopen($file, 'w')?

  • Nothing until you write
  • It is locked but kept
  • It is deleted
  • It is truncated (emptied) immediately

Answer: It is truncated (emptied) immediately. Mode 'w' truncates the file to empty the instant it opens. Use 'a' (append) if you want to keep existing content.

Which fopen mode appends to the end and never erases existing content?

  • 'r'
  • 'a'
  • 'w'
  • 'x'

Answer: 'a'. Mode 'a' (append) sends every write to the end of the file and never erases. 'r' reads, 'w' overwrites.

What does fgets($handle) return when there are no more lines?

  • false
  • An empty string
  • null
  • 0

Answer: false. fgets returns the next line each call and false at the end, which is why while (($line = fgets($h)) !== false) walks the file.

What does file($file) return?

  • The whole file as one string
  • A file handle
  • An array with one element per line
  • The number of bytes

Answer: An array with one element per line. file() reads the whole file into an array, one line per element (use FILE_IGNORE_NEW_LINES to drop trailing newlines).

Why use fgetcsv()/fputcsv() instead of splitting on commas yourself?

  • They are faster
  • They correctly handle commas and quotes inside a field
  • They compress the file
  • They sort the rows

Answer: They correctly handle commas and quotes inside a field. fputcsv/fgetcsv handle quoting and escaping, so a value that contains a comma (like an email) stays a single field.

When reading config back with json_decode(file_get_contents($f), true), what does the true do?

  • Validates the JSON
  • Pretty-prints it
  • Throws on error
  • Returns an associative array instead of an object

Answer: Returns an associative array instead of an object. The true second argument makes json_decode return an associative array rather than a stdClass object.

After scandir($dir), which two entries should you filter out?

  • '.git' and '.env'
  • '.' and '..'
  • 'tmp' and 'log'
  • Hidden files only

Answer: '.' and '..'. scandir always includes '.' (this folder) and '..' (the parent), so filter those out before using the list.

How do you defend against a path-traversal value like '../../etc/passwd'?

  • Trust the browser MIME type
  • Use file_get_contents directly
  • Strip the directory part with basename() and validate against an allow-list
  • Add FILE_APPEND

Answer: Strip the directory part with basename() and validate against an allow-list. basename() throws away any directory part so the .. climbing is gone; keep files in a fixed base folder and allow-list the name.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.

Related lessons