Mid-level 6 min · March 06, 2026

PHP File Handling — Missing fclose() Kills Batch Job

Avoid silent failure: unclosed file handles in PHP hit ulimit after ~800 files, stopping batch jobs.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • fopen() returns a handle — check it before use
  • Mode 'w' truncates instantly; 'a' appends without destroying
  • fgets() streams line-by-line; file_get_contents() loads everything
  • Always close handles or risk exhausting file descriptors (ulimit -n 1024)
  • Use LOCK_EX with file_put_contents() for concurrent write safety
  • file_exists() and is_readable() prevent silent failures on permission issues
Plain-English First

Think of a PHP file like a physical notebook sitting on your desk. To use it, you pick it up (open), you read the pages or scribble on them (read/write), then you put it back down (close). If you forget to put it back, someone else can't use it — and eventually your desk gets cluttered. PHP file handling is exactly that simple: it's just telling your program how to grab a file, do something useful with it, and put it back neatly.

Every web application eventually needs to persist data somewhere. While databases handle structured records beautifully, there are plenty of real-world jobs that plain files do better — logging errors, storing configuration values, generating reports, processing CSV uploads, or caching API responses. Knowing how to handle files correctly in PHP is not optional knowledge; it's a fundamental skill that separates developers who ship reliable software from those who ship mysterious bugs.

The problem most tutorials ignore is the gap between 'it works on my machine' and 'it works safely in production.' PHP gives you raw, powerful file functions — but with that power comes responsibility. An unclosed file handle can silently corrupt data. Writing without checking permissions can crash a script with no helpful error message. Reading a multi-gigabyte log file into a variable can kill your server's memory in seconds. These aren't edge cases; they're Monday-morning incidents.

By the end of this article you'll be able to open files with the right mode for the job, read them line-by-line without memory issues, write and append data safely, check for errors before they bite you, and understand exactly why every step exists — not just how to type it.

Opening and Closing Files — The Contract You Must Always Honor

Every file operation in PHP starts with fopen(). It takes two arguments: the file path and a mode string. The mode tells PHP what you intend to do with the file — and the operating system uses that to decide what access to grant. This isn't just ceremony. If you open a file in read-only mode ('r'), PHP won't let you accidentally write to it, protecting data integrity. If you open with 'w', PHP truncates the file immediately — before you write a single byte. That behaviour surprises a lot of developers.

The return value of fopen() is a file handle — a reference your script holds while the file is open. Think of it as a library card: while you hold it, you're borrowing the file. fclose() returns the card. On most Unix systems, a single process has a limited number of file descriptors it can hold open simultaneously (often 1024). Scripts that open handles in loops without closing them quietly exhaust that limit, causing fopen() to return false with no obvious reason why.

Always pair fopen() with fclose(), and always check that fopen() didn't return false before you try to use the handle. The few lines of error-checking feel tedious until the day they save a production server.

FileOpenClose.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

namespace Io\Thecodeforge\FileHandling;

$logFilePath = __DIR__ . '/app.log';

// Attempt to open the file in append mode.
// 'a' means: write-only, pointer starts at END of file,
// creates the file if it doesn't exist yet.
$fileHandle = fopen($logFilePath, 'a');

// ALWAYS check the return value — fopen() returns false on failure.
// This happens when: the file path is wrong, permissions are denied,
// or the process has too many open handles.
if ($fileHandle === false) {
    // die() stops execution and tells us exactly what went wrong.
    die('Could not open log file at: ' . $logFilePath);
}

// Build a timestamped log entry — a real-world pattern used in every app.
$timestamp   = date('Y-m-d H:i:s');
$logMessage  = "[{$timestamp}] Application started successfully." . PHP_EOL;

// fwrite() writes the string to the file at the current pointer position.
// PHP_EOL adds the correct newline for the current OS (\n on Linux, \r\n on Windows).
$bytesWritten = fwrite($fileHandle, $logMessage);

if ($bytesWritten === false) {
    // The handle was valid but the write still failed (e.g., disk full).
    fclose($fileHandle); // Close before dying — honor the contract.
    die('Failed to write to log file.');
}

echo "Wrote {$bytesWritten} bytes to {$logFilePath}" . PHP_EOL;

// ALWAYS close the handle when you're done.
// This flushes any buffered data to disk and releases the file descriptor.
fclose($fileHandle);

echo 'File handle closed cleanly.' . PHP_EOL;
Output
Wrote 54 bytes to /var/www/html/app.log
File handle closed cleanly.
Watch Out: 'w' Mode Destroys Your File Instantly
Opening a file with mode 'w' truncates it to zero bytes the moment fopen() is called — even if you never call fwrite(). If you want to add to an existing file without destroying it, always use 'a' (append) instead.
Production Insight
A background job that processed 1000+ files without closing handles hit the OS file descriptor limit at 1024.
All subsequent fopen() calls returned false silently, and the job wrote empty output.
Rule: always close handles in the same iteration they're opened — don't rely on script end to clean up.
Key Takeaway
fopen() + fclose() pair is mandatory.
Check the return value before using the handle.
A missing fclose() in a loop will crash your script with no helpful error.

Reading Files the Right Way — Line-by-Line vs. All-at-Once

PHP gives you two main approaches to reading files, and choosing the wrong one is the most common performance mistake in file handling. file_get_contents() loads the entire file into a single string in one call. It's perfect for small configuration files, templates, or JSON files you know are under a few megabytes. It's one of the most readable functions in PHP and should be your first choice when the file is small and you need all the content.

For larger files — log files, CSV exports, data feeds — you need fgets(). It reads one line at a time, keeping memory usage flat no matter how big the file is. Your script uses roughly the same amount of RAM reading a 1 KB file as it does reading a 500 MB file, because it only ever holds one line in memory at once. This is called streaming, and it's the industrial-strength pattern.

feof() tells you whether the file pointer has reached the end of the file. You use it as the condition in a while loop: keep reading lines until we hit the end. It sounds simple, but there's a subtle gotcha: feof() only becomes true after a read attempt fails, not before. So the last iteration of a naive loop can return an empty string — something the code below handles cleanly.

ReadFileMethods.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

namespace Io\Thecodeforge\FileHandling;

// ─── METHOD 1: file_get_contents() ───────────────────────────────────────────
// Best for: small files where you need the full content as a string.
// Worst for: large files — it loads everything into RAM at once.

$configFilePath = __DIR__ . '/config.json';

if (!file_exists($configFilePath)) {
    die('Config file not found: ' . $configFilePath);
}

$configJson   = file_get_contents($configFilePath);
$configData   = json_decode($configJson, associative: true);

echo 'App name from config: ' . ($configData['app_name'] ?? 'unknown') . PHP_EOL;


// ─── METHOD 2: fgets() in a loop ─────────────────────────────────────────────
// Best for: large files — memory stays flat because only one line is in RAM.
// Real-world use: processing large CSVs, parsing log files, reading data feeds.

$logFilePath = __DIR__ . '/app.log';

$fileHandle = fopen($logFilePath, 'r'); // 'r' = read-only, pointer at START

if ($fileHandle === false) {
    die('Cannot open log file for reading: ' . $logFilePath);
}

$lineNumber    = 0;
$errorCount    = 0;
$errorKeyword  = '[ERROR]';

// feof() returns true only AFTER a read hits the end — so check it after reading.
while (($currentLine = fgets($fileHandle)) !== false) {
    $lineNumber++;

    // Trim trailing whitespace/newline characters before processing.
    $trimmedLine = rtrim($currentLine);

    // Count lines that contain our error keyword — a real log-analysis pattern.
    if (str_contains($trimmedLine, $errorKeyword)) {
        $errorCount++;
        echo "Line {$lineNumber} has an error: {$trimmedLine}" . PHP_EOL;
    }
}

// Check if the loop ended because of an error, not just EOF.
if (!feof($fileHandle)) {
    echo 'Warning: Loop ended unexpectedly — possible read error.' . PHP_EOL;
}

fclose($fileHandle);

echo PHP_EOL . "Scan complete. Found {$errorCount} error(s) in {$lineNumber} lines." . PHP_EOL;
Output
App name from config: MyWebApp
Line 3 has an error: [ERROR] Database connection failed at 2024-03-15 09:42:11
Scan complete. Found 1 error(s) in 5 lines.
Pro Tip: Use file() for Small Files You Want as an Array
file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) loads every line into a PHP array automatically. It's a one-liner shortcut for small files, but avoid it for large files — it has the same memory cost as file_get_contents() because it loads everything at once.
Production Insight
A monitoring tool that used file_get_contents() on a 2 GB production log caused the PHP process to consume 2+ GB RAM, triggering OOM kills on a small server.
The fix: switched to fgets() loop. Memory dropped to < 1 MB.
Rule: if filesize() > 1 MB, stream with fgets() — never load whole file.
Key Takeaway
file_get_contents() is for small files only.
fgets() keeps memory constant regardless of file size.
Always check filesize() before deciding which method to use.

Writing and Appending — Choosing the Right Mode for the Job

The difference between writing and appending is one character in your fopen() call, but the consequences of mixing them up are dramatic. Write mode ('w') is a wrecking ball: it empties the file first, then lets you write from a clean slate. Append mode ('a') is a pen: it finds the end of whatever is already there and keeps adding. Using 'w' when you meant 'a' — on a production log file, for example — silently destroys weeks of diagnostic data.

For writing structured data — think generating a CSV report or dumping a JSON snapshot — 'w' is exactly right. You want a fresh file every time. For anything cumulative like logs, audit trails, or user-submitted data you're collecting before batch processing, always use 'a'.

There's a third scenario: what if you need to both read AND write? PHP has combined modes for this: 'r+' reads and writes without truncating (file must exist), and 'w+' reads and writes after truncating (creates file if missing). These are rarely needed in everyday work, but understanding they exist stops you from reaching for two separate fopen() calls when one would do.

WriteAndAppendFile.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace Io\Thecodeforge\FileHandling;

// ─── SCENARIO 1: Generate a fresh CSV report (use 'w' — we want a clean file) ─

$reportFilePath = __DIR__ . '/weekly_report.csv';

$reportHandle = fopen($reportFilePath, 'w');

if ($reportHandle === false) {
    die('Cannot create report file: ' . $reportFilePath);
}

// Write the CSV header row first.
fputcsv($reportHandle, ['Order ID', 'Customer', 'Total', 'Date']);

// Simulate a dataset — in real life this would come from a database query.
$orderRecords = [
    [1001, 'Alice Nguyen',  '89.99',  '2024-03-10'],
    [1002, 'Bob Castillo',  '145.50', '2024-03-11'],
    [1003, 'Carol Okafor', '32.00',  '2024-03-11'],
];

foreach ($orderRecords as $order) {
    // fputcsv() handles quoting and escaping automatically — don't build CSV by hand.
    fputcsv($reportHandle, $order);
}

fclose($reportHandle);
echo 'Report written to: ' . $reportFilePath . PHP_EOL;


// ─── SCENARIO 2: Append a structured event to an audit log (use 'a') ──────────

$auditLogPath = __DIR__ . '/audit.log';

// file_put_contents() with FILE_APPEND is a one-liner for simple appends.
// It handles open, write, and close internally — perfect for quick log writes.
$auditEntry = sprintf(
    '[%s] User #%d (%s) performed action: %s' . PHP_EOL,
    date('Y-m-d H:i:s'),
    42,
    'alice@example.com',
    'EXPORT_REPORT'
);

$bytesWritten = file_put_contents($auditLogPath, $auditEntry, FILE_APPEND | LOCK_EX);
// LOCK_EX acquires an exclusive lock during the write — critical if multiple
// requests could write to the same log file at the same time (race condition).

if ($bytesWritten === false) {
    echo 'Audit log write failed.' . PHP_EOL;
} else {
    echo "Audit entry written ({$bytesWritten} bytes)." . PHP_EOL;
}
Output
Report written to: /var/www/html/weekly_report.csv
Audit entry written (78 bytes).
Interview Gold: LOCK_EX Is Not Optional on Busy Servers
Without LOCK_EX, two simultaneous PHP requests writing to the same file can interleave their output, producing a corrupted file. Always use FILE_APPEND | LOCK_EX together when logging from a web application. Interviewers love asking about this exact race condition.
Production Insight
A logging library omitted LOCK_EX. Under high traffic, two requests wrote to the same log file simultaneously, producing garbled lines like "[2024] [2024] Usser logged in".
The fix: added FILE_APPEND | LOCK_EX to all log writes.
Rule: in concurrent environments, file writes without locking are corrupt waiting to happen.
Key Takeaway
'w' truncates — know when you're destroying data.
'a' appends — safe for logs.
LOCK_EX is mandatory for concurrent writes.
fputcsv() escapes CSV correctly — don't build it manually.

File Utility Functions — The Toolkit Beyond Open and Close

Opening, reading, and writing are the core verbs, but real applications need more. You need to check if a file exists before reading it, know its size before deciding whether to stream it, delete old temp files, rename uploaded files to safe names, and copy backups before overwriting. PHP's file utility functions cover all of this.

file_exists() is your gatekeeper — call it before any operation where the file's absence would cause an error. is_readable() and is_writable() go a step further: a file can exist but be locked down by OS permissions, and these functions tell you so before you waste a call on fopen(). They're especially important when your PHP process runs as www-data and the file is owned by a different user.

filesize() returns the number of bytes in a file. Use it before loading a file into memory so you can decide whether to stream it line-by-line instead. A practical rule: if filesize() is above 1 MB, reach for fgets(). rename() and copy() are atomic and cross-platform — prefer them over shell_exec('mv ...') which is fragile and a security risk if user input is ever involved.

FileUtilities.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?php

namespace Io\Thecodeforge\FileHandling;

$uploadedFilePath = __DIR__ . '/uploads/user_data.csv';
$backupFilePath   = __DIR__ . '/backups/user_data_' . date('Ymd_His') . '.csv';
$maxSafeFileSize  = 1 * 1024 * 1024; // 1 MB in bytes — our streaming threshold

// ─── STEP 1: Does the file actually exist? ────────────────────────────────────
if (!file_exists($uploadedFilePath)) {
    die('Uploaded file not found: ' . $uploadedFilePath);
}

// ─── STEP 2: Can we read it? (Permissions check) ─────────────────────────────
if (!is_readable($uploadedFilePath)) {
    die('Permission denied — cannot read: ' . $uploadedFilePath);
}

// ─── STEP 3: How big is it? (Decide how to read it) ─────────────────────────
$fileSizeBytes = filesize($uploadedFilePath);
$fileSizeKb    = round($fileSizeBytes / 1024, 2);

echo "File size: {$fileSizeKb} KB" . PHP_EOL;

if ($fileSizeBytes > $maxSafeFileSize) {
    echo 'Large file detected — will use streaming (fgets) to protect memory.' . PHP_EOL;
} else {
    echo 'Small file — safe to load entirely with file_get_contents.' . PHP_EOL;
}

// ─── STEP 4: Back it up before processing ────────────────────────────────────
// copy() leaves the original intact. rename() would move it (destructive).
if (!copy($uploadedFilePath, $backupFilePath)) {
    die('Backup failed — aborting to protect original data.');
}

echo 'Backup created at: ' . $backupFilePath . PHP_EOL;

// ─── STEP 5: Clean up temp files older than 7 days ───────────────────────────
$tempDir      = __DIR__ . '/tmp';
$maxAgeInDays = 7;
$cutoffTime   = time() - ($maxAgeInDays * 24 * 60 * 60);

foreach (glob($tempDir . '/*.tmp') as $tempFile) {
    // filemtime() returns the Unix timestamp of the last modification.
    if (filemtime($tempFile) < $cutoffTime) {
        unlink($tempFile); // Delete the stale temp file.
        echo 'Deleted stale temp file: ' . basename($tempFile) . PHP_EOL;
    }
}

echo 'File utility checks complete.' . PHP_EOL;
Output
File size: 342.67 KB
Small file — safe to load entirely with file_get_contents.
Backup created at: /var/www/html/backups/user_data_20240315_094211.csv
Deleted stale temp file: session_cache_a3f9.tmp
File utility checks complete.
Pro Tip: realpath() Stops Directory Traversal Attacks
If any part of your file path comes from user input, always run it through realpath() and verify the result starts with your intended base directory. Without this, an attacker can pass '../../../etc/passwd' as a filename and read sensitive system files.
Production Insight
An upload feature allowed user-supplied relative paths. An attacker used '../../../etc/passwd' as the filename, and the script wrote to that location, corrupting the system password file.
The fix: realpath() on the resolved path and a prefix check against the uploads directory.
Rule: never trust user input in file paths — canonicalise and validate.
Key Takeaway
file_exists() before reading.
is_readable() before fopen().
filesize() before bulk read.
realpath() to prevent traversal attacks.
copy() before destructive operations.

Error Handling and File Permissions — What Breaks in Production

PHP file functions are silent when they fail — they return false, not exceptions. That means you must check every return value yourself. The most common failure scenarios in production: file permissions, disk full, exhausted file descriptors, and missing directories (fopen() does not create parent directories).

For permissions: PHP's process user (usually www-data) must have the correct access on the file and the directory. A common trap: the file has 644 but the parent directory is 700 owned by root, so PHP can't traverse into it. Use is_readable() and is_writable() before fopen().

For missing directories: fopen() fails if the directory doesn't exist. Use is_dir() and mkdir() with recursive=true beforehand.

For disk full: fwrite() returns the number of bytes written. Compare it against the expected length. If it's less, the disk may be full — but PHP won't throw an error. Log the discrepancy.

For resource limits: long-running scripts must monitor file descriptor usage via /proc/self/fd or softlimit. Use register_shutdown_function() to close any leaked handles.

Production-grade code wraps every file operation in a try-catch? No — PHP's file functions don't throw by default. You need to manually check. Use a helper function that throws an exception on failure so you can centralise error handling.

SafeFileOperations.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php

namespace Io\Thecodeforge\FileHandling;

/**
 * Safely read a file's contents with error checking.
 * Throws RuntimeException on failure so you can handle it in one place.
 */
function safeFileGetContents(string $path): string
{
    if (!file_exists($path)) {
        throw new \RuntimeException("File does not exist: $path");
    }
    if (!is_readable($path)) {
        throw new \RuntimeException("File not readable: $path");
    }
    $contents = file_get_contents($path);
    if ($contents === false) {
        throw new \RuntimeException("Failed to read file: $path");
    }
    return $contents;
}

/**
 * Safely write contents to a file with directory creation.
 */
function safeFilePutContents(string $path, string $data, int $flags = 0): int
{
    $dir = dirname($path);
    if (!is_dir($dir)) {
        if (!mkdir($dir, 0755, true)) {
            throw new \RuntimeException("Could not create directory: $dir");
        }
    }
    $bytes = file_put_contents($path, $data, $flags);
    if ($bytes === false) {
        throw new \RuntimeException("Failed to write to file: $path");
    }
    return $bytes;
}

// Example usage:
try {
    $contents = safeFileGetContents(__DIR__ . '/config.json');
    $written = safeFilePutContents(__DIR__ . '/output.txt', $contents, LOCK_EX);
    echo "Success: $written bytes written." . PHP_EOL;
} catch (\RuntimeException $e) {
    error_log($e->getMessage());
    echo "Error: " . $e->getMessage() . PHP_EOL;
}
Output
Success: 1024 bytes written.
Disk Full: The Silent Data Corruptor
When the disk is full, fwrite() and file_put_contents() may return a partial byte count or false. They don't throw. Always check the return value against the expected length. If fewer bytes were written, log it immediately — the file is likely truncated.
Production Insight
A cron job that wrote status files didn't check fwrite() return value. When the disk filled up, it wrote partial files. The monitoring system saw the file existed but was incomplete, triggering false alerts.
The fix: compare actual bytes written to expected strlen(). Throw an exception on mismatch.
Rule: trust the return value of write operations more than the exit code.
Key Takeaway
Check every return value — false means failure.
is_readable() before readable operations.
mkdir() before write to new directories.
Compare written bytes to expected length.
Use helper functions to centralise error handling.
● Production incidentPOST-MORTEMseverity: high

File Descriptor Leak Brought Down a Batch Processor

Symptom
The script would process about 800 files then fail silently. fopen() began returning false for every subsequent file, but the script didn't check the return value and passed false to fwrite(), which triggered a non-obvious warning about 'expects parameter 1 to be resource'
Assumption
The developer assumed the operating system had unlimited file handles. They had written a loop that opened files but never called fclose(), thinking PHP would clean up automatically when the script ended.
Root cause
Each iteration of the processing loop opened a file handle and never closed it. After ~1024 iterations (the default ulimit -n on most Linux systems), the process hit its file descriptor limit. All subsequent fopen() calls failed, but the missing error check masked the real cause.
Fix
Added fclose() after processing each file. Also implemented a counter to log warning when open handles exceeded 900, and added explicit error checking after every fopen() call with a descriptive die() message.
Key lesson
  • Always pair fopen() with fclose() — one per iteration, not one per script.
  • Check fopen() return value immediately — false means something broke.
  • Monitor file descriptor usage in long-running scripts with lsof -p or /proc/self/fd.
Production debug guideSymptom-based quick actions for common file I/O failures4 entries
Symptom · 01
fopen() returns false but file exists
Fix
Run ls -la on the directory and file. PHP's running user (www-data) may lack read/write permission. Use is_readable() and is_writable() to pinpoint the access level.
Symptom · 02
fwrite() writes incomplete content
Fix
Check available disk space with df -h. If full, PHP won't error on fwrite() but returns a smaller byte count. Also verify LOCK_EX is applied for concurrent writes.
Symptom · 03
file_get_contents() kills server memory
Fix
Add a filesize() guard before loading. If file > 1 MB, switch to fgets() streaming. Monitor memory_get_peak_usage() in logs.
Symptom · 04
No error but file is empty after write
Fix
Check if fopen() mode was 'w' — it truncates immediately. If you meant to append, use 'a'. Also verify the write pointer position with ftell().
★ Quick File Handling Debug Cheat SheetCommands and checks for the three most common file I/O failures in production PHP
fopen() returns false on existing file
Immediate action
Check file permissions and process limits.
Commands
ls -la /path/to/file && stat /path/to/file
ulimit -n (check soft limit) or cat /proc/$(pgrep -x php)/limits | grep 'open files'
Fix now
Run chmod 644 /path/to/file and restart PHP-FPM. If file descriptor limit hit, increase ulimit -n 4096 in the service unit.
Write appears successful but file is empty+
Immediate action
Check the fopen mode and write return value.
Commands
php -r "echo var_export(fopen('/path/to/file','r'), true);" (to inspect handle)
grep 'fopen(' your_script.php | head -5
Fix now
If mode was 'w', restore from backup or recreate. Use 'a' for appending. Add bytes-written check after fwrite().
Large file read consumes all RAM+
Immediate action
Kill the PHP process if server is unresponsive.
Commands
ps aux | grep php && kill -9 <PID>
php -r "echo filesize('/path/to/large_file');"
Fix now
Rewrite the script to use fgets() loop. Add filesize() guard > 1MB as streaming threshold. Deploy fix immediately.
file_get_contents/file_put_contents vs fopen/fread/fwrite/fclose
Aspectfile_get_contents() / file_put_contents()fopen() / fread() / fwrite() / fclose()
Code verbosityOne line — very concise4-6 lines minimum — more explicit
Memory usage (large files)Loads entire file into RAM — dangerous for large filesStreams data — memory stays constant regardless of file size
Fine-grained controlLimited — all-or-nothing read/writeFull control: seek position, partial reads, mixed read/write modes
Concurrent write safetyFILE_APPEND | LOCK_EX flags availableflock() for manual locking — more control over lock timing
Error handling granularityReturns false on failure — limited detailEach step returns false separately — easier to pinpoint where failure occurred
Best use caseConfig files, small JSON blobs, quick log appendsCSV row-by-row processing, large log parsing, binary file manipulation
Supports network streamsYes — can read from http:// and ftp:// URLsYes — with the correct stream wrapper
Beginner friendlinessHigh — minimal boilerplateMedium — requires understanding of handles and modes

Key takeaways

1
fopen() mode selection is a data-safety decision, not just syntax
'w' destroys existing content immediately, 'a' preserves it — mixing them up silently corrupts or deletes production data.
2
Always check fopen()'s return value before using the handle
a false handle passed to fgets() or fwrite() produces a misleading type error, not a helpful 'file not found' message.
3
For files larger than ~1 MB, stream line-by-line with fgets() inside a while loop
file_get_contents() on a large file loads the whole thing into RAM and can kill your server.
4
file_put_contents() with FILE_APPEND | LOCK_EX is the correct one-liner for concurrent log writes
omitting LOCK_EX is a race condition waiting to corrupt your log file on any busy server.
5
Check every file function return value
false means failure. Use helper functions that throw exceptions for centralised error handling.
6
Use realpath() on user-supplied paths to prevent directory traversal attacks. Always canonicalise user input before using it in file operations.

Common mistakes to avoid

3 patterns
×

Using 'w' mode when you meant 'a'

Symptom
Your existing file content vanishes silently the instant fopen() is called, before you've written a single byte. On a production log file this can destroy hours of diagnostics.
Fix
Audit every fopen() call and ask 'do I want to keep what's already in this file?' If yes, use 'a'. If you want a clean slate, 'w' is correct — but be conscious of that choice.
×

Never checking fopen()'s return value

Symptom
When fopen() fails it returns false, and the next line tries to call fgets(false) or fwrite(false, ...), producing a confusing 'expects parameter 1 to be resource' warning with no indication of what actually went wrong.
Fix
Always wrap fopen() in an if ($handle === false) { die(...) } check immediately after the call, and include the file path in the error message so you know exactly which file caused the problem.
×

Forgetting LOCK_EX when multiple requests write to the same file

Symptom
On a web server, dozens of PHP requests can run simultaneously. Without a file lock, two requests can interleave their writes mid-line, corrupting the output. The symptom is garbled log entries that look like two lines merged together.
Fix
Always use file_put_contents($path, $data, FILE_APPEND | LOCK_EX) for log writes, or use flock($handle, LOCK_EX) before fwrite() when using the manual handle approach.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between fopen() modes 'r+', 'w', and 'a', and can...
Q02SENIOR
If two PHP processes running simultaneously both try to append to the sa...
Q03SENIOR
You need to process a 2 GB CSV file in PHP without exhausting server mem...
Q04SENIOR
Explain the difference between flock() and LOCK_EX in the context of PHP...
Q01 of 04JUNIOR

What is the difference between fopen() modes 'r+', 'w', and 'a', and can you describe a real scenario where choosing the wrong mode would cause a data loss bug?

ANSWER
'r+' opens for reading and writing without truncating (file must exist). 'w' opens for writing, truncating the file to zero length immediately (creates if not exists). 'a' opens for appending (writing only, pointer at end, creates if not exists). A real data-loss scenario: a developer used 'w' to write a log entry instead of 'a' — each request wiped the entire log file before appending the new line, destroying all historical log data.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between file_get_contents and fgets in PHP?
02
How do I append to a file in PHP without overwriting it?
03
Why does fopen() return false even though the file exists?
04
How do I handle a file that may be written to by multiple PHP processes simultaneously?
05
What's the safest way to handle user-uploaded files in PHP?
🔥

That's PHP Basics. Mark it forged?

6 min read · try the examples if you haven't

Previous
PHP Sessions and Cookies
10 / 14 · PHP Basics
Next
PHP Math Functions