One Call, One String: Reading a File and Catching the Moment It Fails
A settings endpoint hands the contents of config/app.json to the caller. You write $json = file_get_contents('/var/www/config/app.json'); and pass the result to json_decode(). On a machine where the path is wrong, PHP prints Warning: file_get_contents(/var/www/config/app.json): Failed to open stream: No such file or directory and returns false. json_decode(false) quietly gives you null, the endpoint responds with null, and the real failure — a missing file — surfaces three layers away as "why is the response empty?" The read worked exactly as documented; the missing piece was checking what came back.
file_get_contents() loads an entire file into one string in a single call: PHP opens the handle, reads to EOF, closes it, and returns the bytes. No loop, no handle to close, nothing to leak. That makes it the default for anything that fits comfortably in memory — config files, templates, small CSVs, JSON payloads, rendered cache entries. The cost is the implication: the file lives in RAM for as long as the string does, and memory_limit (128M by default) is the ceiling. A 40 MB log file read this way is a bug; a 40 KB config file is free.
Picking a read API is mostly about size and shape:
| API | Returns | Reach for it when |
|---|---|---|
file_get_contents($p) |
one string | the file fits in memory and you want it whole |
file($p) |
array of lines | you need each line as an element and the file is modest |
fopen + fgets |
a loop you control | the file may be large; stream line by line |
SplFileObject |
an object | you want foreach over lines, seeking, and fgetcsv() |
stream_get_contents($h) |
string from a handle | you already hold an open handle or a filter chain |
The failure contract is what interviewers probe: file_get_contents() returns false on failure and raises a warning — it does not throw. With default settings the warning is printed or logged, execution continues, and false walks into the rest of your program. Two disciplined responses:
$path = '/var/www/config/app.json';
if (!is_readable($path)) {
throw new RuntimeException('config unreadable: ' . $path);
}
try {
$data = json_decode(
file_get_contents($path),
false,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException $e) {
throw new RuntimeException('config is not valid JSON', 0, $e);
}
Prefixing with @ is not handling: @file_get_contents() still returns false, it only hides the message. Frameworks go further and install an error handler that converts warnings into ErrorException at the edge of the request, so a failed read becomes a catchable exception — Logging Errors for Production shows where those get routed. Whichever route you take, the rule holds: branch on the return value before the data reaches anything downstream.
One more property worth knowing: when allow_url_fopen is enabled, file_get_contents() also reads http:// and https:// URLs, making it a one-line HTTP client. It obeys the same contract — false plus a warning on a timeout or a 404 — and the timeout itself is configured through a stream context, as in $ctx = stream_context_create(['http' => ['timeout' => 2]]); file_get_contents($url, false, $ctx);.
Append, Overwrite, LOCK_EX: Writing Files That Two Workers Share
Two PHP-FPM workers append the same request line to access.log. Worker A builds its line, worker B builds its line, and the log ends up with a torn record — half of A's line glued to the front of B's. The fix is small, but it only makes sense once you know what file_put_contents() does by default.
Called with just a path and data, file_put_contents() opens the file with O_WRONLY|O_CREAT|O_TRUNC: create if missing, truncate to zero, then write from offset 0. For a whole-file rewrite that is exactly right — old bytes past the end of new data would otherwise survive. For an append you must say so:
// overwrite from byte 0 — the default
file_put_contents('/var/www/cache/manifest.json', $json);
// add a line without destroying what is there
file_put_contents('/var/www/logs/access.log', $line, FILE_APPEND | LOCK_EX);
FILE_APPEND switches the underlying open to O_APPEND, so every write lands at the current end of file — appends from separate processes no longer collide at offset 0. What it does not do is protect a read-modify-write sequence. Incrementing a counter means read the value, add one, write it back; another worker can slip between your read and your write, and one increment vanishes. That is what the lock is for.
The mode you pass to fopen() decides all of it, and the full set is worth having memorized:
| Mode | Creates | Truncates | First position |
|---|---|---|---|
r / r+ |
no | no | byte 0 |
w / w+ |
yes | yes | byte 0 |
a / a+ |
yes | no | end; every write targets EOF |
x / x+ |
yes, fails if it exists | no | byte 0 |
c / c+ |
yes | no | byte 0, read/write without wiping |
The minus-handed distinction is the whole trick: + adds read access to the same handle, a and c are the two modes that leave existing bytes alone, and x is how you claim a filename exclusively — useful when two workers must not write the same output at once.
LOCK_EX acquires an exclusive flock() around the open–write–close window, blocking until the holder releases it. PHP's own loggers pair both flags, and a counter update looks like this:
function bump(string $path): int
{
$fh = fopen($path, 'c+'); // read/write, create if missing, no truncate
if ($fh === false) {
throw new RuntimeException('cannot open ' . $path);
}
try {
if (!flock($fh, LOCK_EX)) {
throw new RuntimeException('lock failed on ' . $path);
}
$n = (int) stream_get_contents($fh);
$n++;
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, (string) $n);
return $n;
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}
Three caveats belong in any honest answer. The lock is advisory — a process that never calls flock() ignores it entirely. It is unreliable on some network filesystems (NFS in particular), so keep coordinated writers on a local disk or move the coordination into a database. And it locks the whole file: fine for a log or a config, a bottleneck if you expected record-level concurrency.
Return values still matter. file_put_contents() returns the number of bytes written or false; on a disk that fills halfway it can return a number smaller than strlen($data), which is a failure even though it is not false. The strict check is:
$written = file_put_contents($path, $data, LOCK_EX);
if ($written === false || $written !== strlen($data)) {
throw new RuntimeException('short write to ' . $path);
}
For content other people read — a config, a rendered page, a cache file — the stronger pattern is atomic replace: write to a temporary file in the same directory, fflush() it, then rename($tmp, $path). rename() within one filesystem is atomic, so a concurrent reader sees either the old file or the new one, never a half-written mixture. Same bytes on disk, completely different failure profile.
Directories, Permissions, and the Return Values You Cannot Skip
A cron job reports that yesterday's export vanished. The write "succeeded" — no exception, no visible warning — because file_put_contents() was pointed at a directory that did not exist, and the warning went to a log nobody reads. Every filesystem function in PHP follows the same contract: a scalar return (or false) plus a warning, never an exception. Checking each one is the difference between a job that fails loudly and one that loses data quietly.
Creating and inspecting the tree first is cheap insurance:
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('cannot create ' . $dir);
}
$files = glob($dir . '/*.csv') ?: []; // false on error → empty list
foreach ($files as $f) {
printf("%s %d bytes %s\n", basename($f), filesize($f), date('Y-m-d', filemtime($f)));
}
The double check around mkdir() matters: two workers can race to create the same directory, and the loser gets false with a File exists warning even though the goal is achieved — is_dir() afterwards proves it. The ?: [] on glob() handles its false return so the foreach never receives a scalar.
Permissions explain most "it worked in dev" stories. Read-side checks deserve the same discipline: is_readable($path) before a read converts the warning into a named failure, and opening an output with the exclusive x mode (fopen($path, 'x')) makes the second worker's create fail loudly instead of clobbering the first worker's file. The mode you pass to mkdir() (or the one a write inherits) is filtered by umask — a process running under umask 022 turns 0775 into 0755. The file must be writable by the user the web server runs as (www-data, nginx), not by you: chmod($path, 0644) for world-readable config, 0700 for anything holding a secret, 0755 for directories. When a write fails with Permission denied, is_writable($dir) before the write converts a late surprise into an early, named error.
Two traps bite even careful code:
- The stat cache. PHP caches
filesize(),filemtime(), andfile_exists()results for the request. Right after writing a file,filesize()may still report the old length until you callclearstatcache()(orclearstatcache(true, $path)for one file). Stale size in a manifest means a truncated download later. - Silent partial operations.
copy(),rename(),unlink(),rmdir(),chmod()all returnfalseon failure.unlink($path)on a missing file warns and returnsfalse— decide explicitly whether "already gone" is success (if (file_exists($path)) unlink($path);) or an error.
For files too large to slurp, stream them instead of trusting memory_limit:
$fh = fopen($export, 'r');
if ($fh === false) { throw new RuntimeException('cannot read ' . $export); }
try {
while (($line = fgets($fh)) !== false) {
$total += strlen($line); // process without holding the file in memory
}
} finally {
fclose($fh);
}
Directory walks scale the same idea: scandir() for one level with SCANDIR_SORT_NONE, DirectoryIterator or RecursiveDirectoryIterator for a tree, each item giving getFilename(), getSize(), isDir() without you concatenating paths by hand — string surgery on paths is exactly what Safe Path Handling exists to replace.
These primitives — a validated destination, a checked write, a byte count confirmed — are the same checklist you will run when the bytes arrive from a browser instead of a script, which is where Handling File Uploads begins.
Quiz
1. file_get_contents('/etc/app.conf') fails because the file is missing. What does PHP do by default?
2. Two workers append lines to the same log file. Which combination guarantees each line lands intact at the end of the file?
3. file_put_contents() returns 512 when strlen($data) is 2048. What is the correct interpretation?
4. Immediately after file_put_contents() rewrites a cache file, filesize() reports the previous length. Why?
Flashcards
Question
What does file_get_contents() return on failure, and how should you handle it?
Click to reveal answer
Answer
false, plus a warning — never an exception under default error handling. Check with if ($data === false) (or test is_readable() first) and fail at the read site; @-suppression hides the message but still returns false, so it is not error handling.
Question
Default behavior of file_put_contents(): what flags are applied, and when is that wrong?
Click to reveal answer
Answer
It opens with O_WRONLY|O_CREAT|O_TRUNC — create if missing, truncate to zero, write from byte 0. Correct for whole-file replacement, wrong for appending (needs FILE_APPEND) and wrong when another process may be reading mid-write (needs LOCK_EX or write-to-temp plus rename).
Question
FILE_APPEND vs LOCK_EX — what does each one actually protect?
Click to reveal answer
Answer
FILE_APPEND changes the open mode to O_APPEND so every write targets the current EOF, preventing offset collisions between appenders. LOCK_EX takes an advisory flock around the operation, serializing whole read-modify-write sequences. Appends need FILE_APPEND; counters and multi-step updates need LOCK_EX (often both together).
Question
How do you publish a file so readers never see half of it?
Click to reveal answer
Answer
Write the new content to a temp file in the same directory, fflush/close it, then rename($tmp, $dest). rename() within one filesystem is atomic, so concurrent readers get either the complete old file or the complete new one — never a mixture.
Question
Which PHP functions cache filesystem facts, and what goes wrong after a write?
Click to reveal answer
Answer
The stat family: filesize(), filemtime(), file_exists(), is_file() results are memoized for the request. After writing or deleting, call clearstatcache() (or clearstatcache(true, $path)) or your code keeps acting on the pre-write values — the classic symptom is a manifest listing a stale file size.
Revision Notes
Key Takeaways
- 1. file_get_contents() is a one-call slurp that returns false plus a warning on failure — check the return before the data reaches json_decode or concatenation
- 2. file_put_contents() truncates by default; append needs FILE_APPEND, mutual exclusion needs LOCK_EX, and a byte count below strlen($data) is a failed write
- 3. Advisory flock only coordinates processes that also call flock, and it is unreliable on NFS — record-level concurrency belongs in the database
- 4. Write to a temp file and rename() to publish: atomic replacement means readers see the old file or the new one, never a torn mixture
- 5. PHP caches stat results per request and fs functions never throw, so clearstatcache() and explicit return checks are what keep exports and manifests honest
Interview Tips
- • "Difference between FILE_APPEND and LOCK_EX?" — FILE_APPEND fixes *where* bytes land (O_APPEND, at EOF, so appenders cannot collide at offset 0); LOCK_EX fixes *when* they land (flock serializes the whole read-modify-write). Answer with the counter-increment example to show you know appends alone do not protect it
- • "How do you write a file atomically?" — temp file in the same directory, write, flush, rename; explain that rename is atomic on the same filesystem so readers never observe partial content, and that a cross-filesystem rename degrades to copy-plus-delete
- • "Why does file_get_contents not throw?" — legacy contract: false plus warning; mention that frameworks convert warnings to ErrorException via set_error_handler and that checking === false is the minimum
- • "Disk fills up mid-write — what happens?" — file_put_contents/fwrite return a byte count below the requested length (or false), leaving truncated data; the strict check compares against strlen($data), and for published files the temp-plus-rename pattern makes the truncation harmless
Cheat Sheet
Reading and Writing Files
Reading:
$data = file_get_contents($p); // whole file → string | false (+ warning)
$lines = file($p); // array of lines | false
$fh = fopen($p, 'r'); // handle; stream with fgets()/fgetcsv()
while (($line = fgets($fh)) !== false) { ... }
| Need | API |
|---|---|
| small file, whole | file_get_contents |
| lines as array | file() |
| large file, low memory | fopen + fgets / SplFileObject |
| HTTP body | file_get_contents($url, false, $ctx) |
Writing:
file_put_contents($p, $data); // truncate + write from 0
file_put_contents($p, $line, FILE_APPEND | LOCK_EX); // shared append, flock'd
$n = file_put_contents($p, $d, LOCK_EX);
if ($n === false || $n !== strlen($d)) { /* short write → fail */ }
Atomic publish: write $p.tmp in the same dir → fflush/close → rename($tmp, $p) (atomic within a filesystem).
fopen modes: r read · r+ read/write · w truncate/create · w+ · a append/create · a+ · x fail if exists · c create no truncate, position 0.
Checklist: every fs function returns false + warning, never an exception → check the return; mkdir race needs !mkdir(...) && !is_dir(...); stat results need clearstatcache() after writes; permissions = mode × umask, owner must be the web-server user. flock is advisory and unreliable on NFS.