Working with Text Files in PHP
PHP reads small text files with file_get_contents() and writes them with file_put_contents(). For a large log, read one line at a time so the whole file does not have to fit in memory.
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.
Let your application choose which files it can open. A visitor can select a document from an approved list, but should not be able to submit any path on your server.
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.
How Do You Read a Text File in PHP?
Read a small text file in PHP with file_get_contents(), which returns its contents as a string:
<?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.
How Do You Read Large Files in PHP?
Read a large file in PHP one line at a time with SplFileObject so the whole file does not need to fit in memory. The example calls an application-provided processLogLine() function:
<?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));
}
if (!feof($handle)) {
fclose($handle);
throw new RuntimeException("Could not finish reading the log.");
}
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.
How Do You Write a Text File in PHP?
Write a small text file in PHP with file_put_contents(), which replaces existing contents unless you request append mode:
<?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. The PHP flock() manual describes locks as advisory on common systems, so every cooperating writer has to use the same discipline. Readers that need a consistent view must also use a shared lock.
How Do You Append to a File in PHP?
Append text in PHP by passing FILE_APPEND to file_put_contents(). Add LOCK_EX to coordinate with other writers using the same locking scheme:
<?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.
Which PHP File Modes Erase Existing Contents?
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 {
if (realpath(dirname($tempPath)) !== realpath($directory)) {
throw new RuntimeException("Temporary file is outside the target directory.");
}
$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);
}
} Check that the temporary file was created in the requested directory. On a local filesystem that supports atomic replacement, renaming it over the destination lets readers see either the old file or the complete new one. Check replacement behaviour on your deployment filesystem; this does not prevent two writers from overwriting each other.
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.
Prefer server-owned identifiers over filenames submitted by a browser:
<?php
$allowedFiles = [
"terms" => "terms.txt",
"privacy" => "privacy.txt",
];
$key = $_GET["file"] ?? "terms";
$filename = is_string($key) ? ($allowedFiles[$key] ?? $allowedFiles["terms"]) : $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. An unrecognised key falls back to the terms file.
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");
if ($base === false) {
throw new RuntimeException("Document directory is missing.");
}
$paths = [];
foreach ($fileKeys as $key) {
if (!is_string($key) || !isset($allowedFiles[$key])) {
throw new InvalidArgumentException("Unknown document key.");
}
$path = realpath($base . "/" . $allowedFiles[$key]);
if ($path === false || !is_file($path)
|| !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
throw new RuntimeException("Document is unavailable.");
}
$paths[$path] = basename($path);
}
if ($paths === []) {
throw new InvalidArgumentException("Choose at least one document.");
}
$zipPath = tempnam(sys_get_temp_dir(), "cw-");
if ($zipPath === false) {
throw new RuntimeException("Could not create temporary file.");
}
$zip = new ZipArchive();
$opened = false;
try {
if ($zip->open($zipPath, ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException("Could not open ZIP archive.");
}
$opened = true;
foreach ($paths as $path => $name) {
if (!$zip->addFile($path, $name)) {
throw new RuntimeException("Could not add document.");
}
}
$closed = $zip->close();
$opened = false;
if (!$closed) {
throw new RuntimeException("Could not finish ZIP archive.");
}
return $zipPath;
} catch (Throwable $error) {
if ($opened) {
$zip->close();
}
if (is_file($zipPath)) {
unlink($zipPath);
}
throw $error;
}
} 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.
When Should PHP Use a Database Instead of Files?
Use a database when PHP needs to search related records, coordinate concurrent updates, or make several changes together. Text files remain useful for simpler 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. A database is easier to manage when you need to filter rows, relate records, or coordinate updates.
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
Can PHP file_get_contents() read a URL?
PHP file_get_contents() can fetch an HTTP or HTTPS URL when allow_url_fopen is enabled. Stream context options let you set headers and a read timeout. An HTTP client library can make response handling and retries easier for API requests.
Does file_put_contents() create a missing file?
PHP 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?
PHP fopen() modes a and ab 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?
CSV files can be queried with SQL through features such as PostgreSQL file_fdw and SQLite’s CSV virtual 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?
PHP can send a ZIP archive without first saving it by using a library designed for streaming ZIP files. ZipArchive uses a filesystem path, so a ZipArchive download normally requires a temporary file that you delete after sending it.
Choose the Right PHP File Function
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.
For records you need to query or update together, the PHP and MySQL with PDO guide is the next step.
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.
A beginner's introduction to PHP: whether it is worth learning in 2026, what runs on it, PHP or Node, whether it is secure, how coding agents change the work, and a roadmap to every PHP guide.