Working with Text Files in PHP
PHP can still read and write text files perfectly well. The part that changed is the standard you should hold the code to.
File I/O is one of the first practical things people want from PHP: read a log, write a counter, update a flat-file guestbook, or parse a simple text export before a database enters the picture. The need has not changed, and trusting a filename that arrived from a form is still the habit to drop.
Treat the filesystem like a storeroom with labelled shelves. Application code chooses the room and shelf, while user input may choose only an approved item. Reads open one known container, locks coordinate access, and data with relationships belongs in a database instead.
Guide Path
This is the first files-and-data tutorial in the PHP path. Read it before processing XML with PHP or streaming media with PHP, because both depend on safe path handling, predictable reads, and clear storage boundaries.
Reading a Small File
For a small text file, file_get_contents() is the plain tool:
<?php
$path = __DIR__ . "/../storage/message.txt";
$contents = file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Could not read message file.");
}
echo htmlspecialchars($contents, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8"); That is fine for a config snippet, a short template fragment, or a small text export. It is not the right shape for a 900 MB access log because the whole file lands in memory.
Once the file can be large, read it line by line.
Reading Line by Line
SplFileObject gives you a tidy iterator around a file:
<?php
$file = new SplFileObject(__DIR__ . "/../storage/access.log");
foreach ($file as $line) {
$line = trim($line);
if ($line === "") {
continue;
}
processLogLine($line);
} The older fopen() and fgets() pattern still works too:
<?php
$handle = fopen(__DIR__ . "/../storage/access.log", "rb");
if ($handle === false) {
throw new RuntimeException("Could not open log file.");
}
while (($line = fgets($handle)) !== false) {
processLogLine(trim($line));
}
fclose($handle); The "rb" mode is deliberate because PHP's fopen() documentation still has a long note about text mode versus binary mode and line endings. In practice, use binary mode for portability unless you have a specific Windows text-mode reason.
Writing a Small File
file_put_contents() is the compact version when the whole write fits cleanly in memory:
<?php
$path = __DIR__ . "/../storage/status.txt";
$bytes = file_put_contents($path, "ready\n", LOCK_EX);
if ($bytes === false) {
throw new RuntimeException("Could not write status file.");
} LOCK_EX asks PHP to take an exclusive lock while writing. It is not magic, and the PHP flock() manual describes locks as advisory on common systems, so every cooperating writer has to use the same discipline. Think of it as the storeroom key: it works only when every writer agrees to use the door.
Appending Safely
Append mode is useful for logs because it writes at the end:
<?php
$entry = sprintf(
"[%s] %s\n",
(new DateTimeImmutable())->format(DateTimeInterface::ATOM),
"import finished",
);
$bytes = file_put_contents(
__DIR__ . "/../storage/import.log",
$entry,
FILE_APPEND | LOCK_EX,
);
if ($bytes === false) {
throw new RuntimeException("Could not append to import log.");
} This is fine for modest logs and audit trails. If the file is hot, shared across processes, or business-critical, stop treating a text file as infrastructure and put the event into a database or a real log system.
If the file is protected media rather than a log or export, the shape changes. Use the streaming media with PHP guide for the boundary between PHP authorization and web-server file delivery.
The Mode that Bites People
The PHP fopen() manual states that the "w" family truncates an existing file to zero length when it opens.
<?php
$handle = fopen($path, "wb"); That behavior is right for a deliberate replacement and destructive when the plan was to inspect or edit the current contents.
For a replacement, write and verify a temporary file in the same directory, then rename it over the live path:
<?php
$directory = dirname($path);
$tempPath = tempnam($directory, ".replace-");
if ($tempPath === false) {
throw new RuntimeException("Could not create replacement file.");
}
try {
$bytes = file_put_contents($tempPath, $newContents, LOCK_EX);
if ($bytes === false || $bytes !== strlen($newContents)) {
throw new RuntimeException("Could not write complete replacement file.");
}
if (!rename($tempPath, $path)) {
throw new RuntimeException("Could not replace live file.");
}
} finally {
if (is_file($tempPath)) {
unlink($tempPath);
}
} Keeping the temporary file in the same directory keeps it on the same filesystem, so rename() can replace the live path atomically after the complete write succeeds.
Never Trust a Submitted Path
Filesystem code becomes dangerous when user input chooses the path:
<?php
$path = __DIR__ . "/../storage/" . $_GET["file"];
echo file_get_contents($path); That code invites path traversal because a user can submit ../ segments and reach files the script never meant to expose. The PHP filesystem security docs use the same kind of example for a reason.
Prefer server-owned identifiers over filenames submitted by a browser:
<?php
$allowedFiles = [
"terms" => "terms.txt",
"privacy" => "privacy.txt",
];
$key = $_GET["file"] ?? "terms";
$filename = $allowedFiles[$key] ?? $allowedFiles["terms"];
$base = realpath(__DIR__ . "/../storage");
if ($base === false) {
throw new RuntimeException("Storage directory is missing.");
}
$path = realpath($base . "/" . $filename);
if ($path === false || !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
throw new RuntimeException("Invalid file path.");
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Could not read file.");
}
echo htmlspecialchars($contents, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8"); The user chooses a known key while the server chooses the actual filename. The storeroom boundary stays intact because the request never receives control over the room or shelf.
ZIP Downloads
The old ZIP File Maker snippet answered a real need: take a few server files and hand the user one download. In current PHP, reach for ZipArchive and keep the same path discipline you used for reads.
<?php
function buildDownloadZip(array $fileKeys): string
{
$allowedFiles = [
"terms" => "terms.txt",
"privacy" => "privacy.txt",
"readme" => "readme.txt",
];
$base = realpath(__DIR__ . "/../storage/public-docs");
$zipPath = tempnam(sys_get_temp_dir(), "cw-");
if ($base === false || $zipPath === false) {
throw new RuntimeException("Could not prepare ZIP file.");
}
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException("Could not open ZIP archive.");
}
foreach ($fileKeys as $key) {
if (!isset($allowedFiles[$key])) {
continue;
}
$path = realpath($base . "/" . $allowedFiles[$key]);
if ($path === false || !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
continue;
}
$zip->addFile($path, basename($path));
}
$zip->close();
return $zipPath;
} Do not pass arbitrary submitted paths into the archive. The user can choose a known document key, but the server still chooses the path and archive name. After sending the ZIP, delete the temporary file so the download directory does not become a quiet storage system.
Directory Listing
A directory listing is another good example of useful file code that needs a strict boundary. You may want an admin page that lists uploads, exports, or generated reports. That does not mean the browser should choose the directory.
<?php
function listReportFiles(): array
{
$base = realpath(__DIR__ . "/../storage/reports");
if ($base === false) {
throw new RuntimeException("Reports directory is missing.");
}
$files = [];
foreach (new DirectoryIterator($base) as $entry) {
if (!$entry->isFile()) {
continue;
}
$files[] = [
"name" => $entry->getFilename(),
"size" => $entry->getSize(),
"updated_at" => $entry->getMTime(),
];
}
usort($files, fn ($a, $b) => $b["updated_at"] <=> $a["updated_at"]);
return $files;
} Render those filenames with htmlspecialchars() the same way you render any other value. File names are not safe just because the server produced them; they may still include characters that mean something in HTML. If users can upload files, store a server-owned name and keep the original display name as metadata.
Files vs Database
Text files still earn their keep for small, file-shaped jobs:
- Small config snapshots.
- Logs that another tool will collect.
- Imports and exports.
- Static text fragments.
- Small ZIP downloads made from server-approved files.
- One-off scripts where the file is the point.
They are a poor fit once the data starts behaving like application state:
- User accounts.
- Comments, posts, orders, and messages.
- Anything that needs joins.
- Anything that needs reliable concurrent writes.
- Anything you expect to search, paginate, or report on later.
That is where the small PHP database app shape becomes cleaner. If the file is a spreadsheet export, use the CSV-to-SQL import guide before the rows touch final tables. Once the file has rows, ownership, and queries, it is asking to become SQL.
Common Pitfalls & Debugging
A Read Failure Looks Like Empty Content
Symptom: a missing or unreadable file is treated as an empty document. Cause: the code uses a loose check, so false and an empty string are handled alike. Fix: compare the result with === false, then report or throw the real read error.
Two Writers Lose an Update
Symptom: a counter or small state file occasionally loses a change. Cause: each request reads before taking the exclusive lock, so both calculate from the same old value. Fix: open the file, lock it, then perform the complete read-modify-write sequence before releasing the lock.
A Resolved Path Leaves the Storage Root
Symptom: a request can read a neighboring directory with a similar prefix. Cause: the path check compares text prefixes without a directory boundary. Fix: resolve both paths and require the file path to begin with the resolved base plus DIRECTORY_SEPARATOR.
Frequently Asked Questions
Does file_get_contents work on a URL?
Yes, when allow_url_fopen is enabled it fetches a remote address the same way it reads a local path. A real HTTP client is still the better tool, because it gives you timeouts, headers, and error handling.
Does file_put_contents() create a missing file?
Yes. file_put_contents() creates the file when the parent directory exists and PHP has permission to write there. It returns false on failure, so compare the result strictly and handle that case instead of assuming the write succeeded.
Which fopen() mode appends without truncating?
Use a or ab to open a file for writing at the end without truncating its existing contents. The a+ and a+b modes also permit reading. Add the b flag for consistent binary-mode behavior across platforms.
Can you query a CSV file directly with SQL?
Some engines allow it: PostgreSQL through file_fdw and SQLite through its CSV virtual table can both read a flat file as though it were a table. Indexing and performance stay far more limited than an imported table.
Can PHP send a ZIP archive to the browser without writing it to disk first?
Not with ZipArchive, which works against a real filesystem path rather than a stream. The practical pattern is writing to a temporary file, streaming it to the browser, then deleting it immediately. Libraries built specifically for streaming archives exist when a temporary file is genuinely unacceptable.
The Practical Rule
Use file_get_contents() and file_put_contents() for small, clear jobs. Use streams or SplFileObject for large files. Lock the complete update when more than one request might write, and let application code own every path.
And when the file starts pretending to be a table, let it become one. The PHP and MySQL with PDO guide is the next step once that boundary appears.
This example has been updated from the original CodeWalkers file-handling tutorials, which
took a filename straight from a form and opened it. Every path in this guide is resolved with
realpath() and checked against a storage root because of that one shortcut.
Sources
-
[1]
PHP fopen(php.net)
-
[2]
PHP file_get_contents(php.net)
-
[3]
PHP file_put_contents(php.net)
-
[4]
PHP flock(php.net)
-
[5]
PHP SplFileObject(php.net)
-
[6]
PHP Filesystem Security(php.net)
-
[7]
PHP ZipArchive(php.net)
-
[8]
PHP DirectoryIterator(php.net)
Read Next
Secure ordinary PHP surfaces with current password hashing, sessions, CSRF tokens, output escaping, prepared statements, and upload controls.
Move CSV data into MySQL, PostgreSQL, SQLite, or a PHP/PDO import flow without losing validation, transactions, or schema discipline.
Modern PHP reference guides for frameworks, PDO, configuration, pagination, conditionals, forms, files, sockets, media streaming, mini chat apps, XML, templates, OOP, APIs, email, SOAP, recursion, SQL-backed apps, and security fundamentals.