Reading and Writing Files in PHP

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

PHP 8.4

Files are the simplest persistence layer: logs, configuration, exports, caches. PHP offers everything from a one-line read to a streaming handle.

One-shot read and write

// read the whole file
$content = file_get_contents("data.txt");

// write (overwrites)
file_put_contents("out.txt", "hello\n");

// append
file_put_contents("log.txt", "another line\n", FILE_APPEND);

file_put_contents is equivalent to fopen + fwrite + fclose and is enough for simple cases. It returns the number of bytes written, or false on failure.

A one-shot read loads the entire file into memory — a few hundred megabytes will hit the memory limit. Stream those instead.

Handling read failures

$content = @file_get_contents("missing.txt");   // @ suppresses the warning — avoid
if ($content === false) {
    // handle it
}

Clearer: check first.

$path = "data.txt";

if (!is_file($path) || !is_readable($path)) {
    throw new RuntimeException("File is not readable: {$path}");
}

$content = file_get_contents($path);

Avoid @: it hides the problem and makes debugging harder.

Reading line by line

file() returns an array of lines (with their trailing newlines unless you strip them):

$lines = file("data.txt", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

foreach ($lines as $line) {
    echo $line . PHP_EOL;
}

$names = array_values(array_filter(array_map("trim", $lines)));

Streaming: for large files

$handle = fopen("big.log", "r");

if ($handle === false) {
    throw new RuntimeException("Cannot open file");
}

while (($line = fgets($handle)) !== false) {
    // one line at a time, constant memory
    if (str_contains($line, "ERROR")) {
        echo $line;
    }
}

fclose($handle);

fgets reads one line; false means end of file. It keeps the trailing newline, so trim before comparing.

Writing and locking

Concurrent writers overwrite each other, so lock:

file_put_contents("counter.txt", "1", LOCK_EX);

LOCK_EX is an exclusive lock: other processes wait (it works across processes via flock).

For a read-modify-write cycle, lock the handle explicitly:

$handle = fopen("counter.txt", "c+");   // read/write, create if missing, no truncation
flock($handle, LOCK_EX);

$current = (int) stream_get_contents($handle);
$current++;

rewind($handle);
ftruncate($handle, 0);
fwrite($handle, (string) $current);

flock($handle, LOCK_UN);
fclose($handle);

Directories

// create, including parents
if (!is_dir("storage/logs")) {
    mkdir("storage/logs", 0755, true);
}

// list entries
foreach (scandir("storage") as $entry) {
    if ($entry === "." || $entry === "..") {
        continue;
    }
    echo $entry . PHP_EOL;
}

For recursion, the SPL iterators beat hand-rolled recursion:

$iterator = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator("src", FilesystemIterator::SKIP_DOTS)
);

foreach ($iterator as $file) {
    if ($file->isFile() && $file->getExtension() === "php") {
        echo $file->getPathname() . PHP_EOL;
    }
}

FilesystemIterator::SKIP_DOTS removes . and ...

Paths

__DIR__;                       // absolute directory of the current file
dirname(__FILE__);             // same, older style
basename("/a/b/c.txt");        // "c.txt"
pathinfo("/a/b/c.txt");        // dirname, basename, extension, filename

Build paths from __DIR__ rather than relying on relative paths or the working directory:

$logFile = __DIR__ . "/storage/app.log";

Under PHP-FPM the working directory differs from the CLI’s, which is exactly how “works on the command line, 404 in the browser” file bugs happen.

File information

$path = __DIR__ . "/data.txt";

file_exists($path);     // file or directory exists
is_file($path);         // regular file
is_dir($path);          // directory
is_readable($path);     // readable
is_writable($path);     // writable
filesize($path);        // bytes, false on failure
filemtime($path);       // last modified, Unix timestamp

Deleting, renaming, copying:

unlink("old.txt");                    // delete
rename("a.txt", "b.txt");             // rename or move
copy("a.txt", "backup/a.txt");        // copy

Worked example: a small logger

<?php
declare(strict_types=1);

function appLog(string $level, string $message, string $dir): void
{
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $file = $dir . "/" . date("Y-m-d") . ".log";
    $line = sprintf(
        "[%s] %s: %s%s",
        date("H:i:s"),
        strtoupper($level),
        $message,
        PHP_EOL
    );

    file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}

appLog("error", "Database connection failed", __DIR__ . "/storage/logs");

Resulting storage/logs/2026-11-03.log:

[14:32:10] ERROR: Database connection failed

Common mistakes

1. Relative paths

The working directory differs between CLI and web. Always start from __DIR__.

2. file_get_contents on a huge file

Memory exhaustion. Stream with fopen + fgets.

3. Concurrent writes without a lock

Writes clobber each other. Use LOCK_EX or flock.

4. Using @ instead of checking

Errors disappear but the code keeps running in a broken state. Check is_file / is_readable first.

5. Forgetting fclose

Long-running scripts leak descriptors until they hit the limit.

6. Overly permissive permissions

0777 is a security problem. Start at 0755 for directories and 0644 for files, and grant the minimum needed.

Summary

  • Small files: file_get_contents / file_put_contents. Large files: fopen + fgets.
  • Append with FILE_APPEND, lock concurrent writes with LOCK_EX.
  • Build paths from __DIR__, never from the current working directory.
  • Check is_file / is_readable / is_writable instead of suppressing errors.
  • Close what you open.

Next: errors and exceptions — what to catch, what to fix, and what to show users.