PHP File Handling — Missing fclose() Kills Batch Job
Avoid silent failure: unclosed file handles in PHP hit ulimit after ~800 files, stopping batch jobs.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
- 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
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.
PHP File Handling — The Hidden Cost of Open Handles
PHP file handling is the set of functions (fopen(), fwrite(), fread(), fclose()) that manage file stream resources. The core mechanic: fopen() returns a resource handle that PHP tracks internally; every open handle consumes a file descriptor from the OS. On Linux, the default per-process limit is 1024 descriptors. Exceed it, and fopen() returns false — silently. The handle also holds a write buffer; fclose() flushes that buffer and releases the descriptor. Without fclose(), the buffer may never be written, and the descriptor leaks until the script ends. In long-running processes (daemons, workers, batch jobs), leaked descriptors accumulate until the process hits the OS limit, then every subsequent fopen() fails. The failure is silent: fopen() returns false, but code that doesn't check the return value continues as if the file is open, leading to data loss or corrupted output. Use file handling when you need sequential read/write, random access via fseek(), or streaming large files that don't fit in memory. In production, always pair fopen() with fclose() in a try/finally block, and never assume fopen() succeeded — check the return value.
fclose() or risk silent data loss in long-running scripts.fclose() hits the 1024 descriptor limit halfway through, then silently writes to /dev/null because fopen() returns false and the code doesn't check it.fopen(), there must be a corresponding fclose() in a finally block — treat file handles like database connections.fclose() in long-running scripts causes silent fopen() failures when the descriptor limit is hit.fclose() runs, even on exceptions.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.
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.fopen() calls returned false silently, and the job wrote empty output.fclose() pair is mandatory.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.
file_get_contents() because it loads everything at once.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.fgets() loop. Memory dropped to < 1 MB.filesize() > 1 MB, stream with fgets() — never load whole file.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.
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.
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.realpath() on the resolved path and a prefix check against the uploads directory.fopen().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.
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.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.strlen(). Throw an exception on mismatch.File Modes Are a Loaded Weapon — Choose Wisely
Every fopen() call demands a mode string. That second parameter—'r', 'w', 'a', 'x'—is not a suggestion. It's a contract with the operating system. Get it wrong and you'll silently truncate production logs, overwrite user data, or fail to create a file you thought you'd opened.
The mode defines three things: where the pointer starts, whether the file must exist, and whether the filesystem is allowed to create or destroy data. 'w' deletes everything before you write a byte. 'a' never overwrites. 'x' fails if the file already exists—safest for config files. 'r+' and 'w+' give you read+write, but 'w+' still truncates. Never use 'w+' unless you explicitly want to nuke the file.
Pick the mode that matches your exact intent. Document it in a comment. Your future self—and the on-call engineer at 3 AM—will thank you.
Reading Files: Streams, Not Magic — Choose Your Weapon
You have two combat strategies for reading a file: pull the whole thing into memory with file_get_contents(), or walk through it line by line with fgets(). Your choice determines how much RAM you burn and how your app behaves under load.
file_get_contents() is the bazooka. Fast, simple, deadly on small files. For configs, templates, or any file under 1 MB, it's the right call. But point it at a 500 MB CSV and your memory limit evaporates. Your process gets OOM-killed, and your monitoring dashboard lights up red.
fgets() is the scalpel. It reads one line per call, yielding memory pressure that's flat, not spiking. Use it for log files, large datasets, or any stream where the size is unknown. Pair it with feof() to know when you're done, never while().
There's also fread() for binary files—images, archives, database dumps. It reads a fixed number of bytes. If you're not handling text, use fread() with a buffer size matching your hardware page size (usually 4096 or 8192).
fgets() with a generator. It yields lines without holding any array in memory—saves RAM and keeps your response time predictable.File Descriptor Leak Brought Down a Batch Processor
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'fclose(), thinking PHP would clean up automatically when the script ended.fopen() calls failed, but the missing error check masked the real cause.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.- Always pair
fopen()withfclose()— 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.
is_readable() and is_writable() to pinpoint the access level.fwrite() but returns a smaller byte count. Also verify LOCK_EX is applied for concurrent writes.filesize() guard before loading. If file > 1 MB, switch to fgets() streaming. Monitor memory_get_peak_usage() in logs.fopen() mode was 'w' — it truncates immediately. If you meant to append, use 'a'. Also verify the write pointer position with ftell().ls -la /path/to/file && stat /path/to/fileulimit -n (check soft limit) or cat /proc/$(pgrep -x php)/limits | grep 'open files'Key takeaways
fopen()'s return value before using the handlefgets() or fwrite() produces a misleading type error, not a helpful 'file not found' message.fgets() inside a while looprealpath() on user-supplied paths to prevent directory traversal attacks. Always canonicalise user input before using it in file operations.Common mistakes to avoid
3 patternsUsing 'w' mode when you meant 'a'
fopen() is called, before you've written a single byte. On a production log file this can destroy hours of diagnostics.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
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.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
fwrite() when using the manual handle approach.Interview Questions on This Topic
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?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's PHP Basics. Mark it forged?
8 min read · try the examples if you haven't