Web Development

Atomic File Replacement in PHP - Let Readers See the Old File or the New One

Atomic File Replacement in PHP - Let Readers See the Old File or the New One

A small PHP application may keep a generated manifest, a JSON settings file, or a tiny cache on disk. Writing one is easy. The harder question is what another request sees while that file is being replaced. Can it open a complete old version or a complete new version, or might it catch the destination after truncation and before the final byte arrives?

For whole-file updates on a local Linux filesystem, a useful pattern is to write a temporary file beside the destination and then rename it over the old file. That short description hides several conditions, however. Atomic visibility is not the same as coordinating competing writers, and neither is the same as surviving a power loss. This article separates those guarantees and builds a deliberately limited PHP helper around them.

Why rewriting the destination directly is fragile

Opening an existing file in a truncating mode such as wb reduces its length before the replacement content has been written. The next write may also complete only part of the requested data: PHP documents the return value of fwrite() as the number of bytes written, or false on failure. Treating one call as an all-or-nothing operation therefore assumes more than the API promises.

Imagine replacing a valid 20 KB JSON document. A reader arriving at the wrong moment could encounter an empty file, a valid prefix that ends abruptly, or a complete document. A lock can make cooperating readers wait, but every reader then has to participate. It is often simpler to keep the published path untouched while constructing the next version elsewhere.

Three guarantees, not one

1. Atomic visibility

The POSIX specification for rename() says that when the destination already exists, its directory entry remains visible throughout the operation and refers either to the old file or the new file. On Linux, the rename(2) manual describes replacement as atomic: another process looking up the destination does not find a moment when it is absent.

This is the property that prevents a normal reader from opening a half-copied destination. A reader that already has the old file open may continue reading that old file; a later open resolves to the replacement. That behavior is usually desirable for generated snapshots.

2. Writer coordination

Atomic replacement does not decide which of two simultaneous writers should win. Each writer can prepare a valid temporary file, and the later successful rename can replace the earlier result. No partial file appears, but a logical update may still be lost.

If writers perform read-modify-write work, use one stable lock file to cover the whole sequence. PHP describes flock() as advisory locking: it coordinates only processes that follow the same locking convention. This is application cooperation, not a barrier against unrelated programs.

3. Crash durability

A successful function return and an atomic name switch do not automatically prove that every storage layer has made the change durable. PHP 8.1 introduced fsync(), which asks the operating system to synchronize a file's data and metadata to storage. Syncing the temporary file before renaming strengthens the sequence, but the rename also changes a directory entry. Strict crash-consistency requirements can require synchronizing the containing directory through platform-specific facilities and considering the filesystem, mount options, drive cache, and hardware.

That distinction matters. “Readers do not see half a file” is a narrower and more defensible promise than “the newest file survives every crash.”

A bounded PHP implementation

The following helper targets a regular local filesystem on POSIX/Linux. It creates the temporary file in the destination directory, confirms that tempnam() did not fall back elsewhere, writes every byte, optionally synchronizes the file, closes it, and then replaces the destination. Every failure before the rename leaves the existing destination alone and attempts to remove the temporary file.

<?php
function atomicReplace(string $path, string $data, bool $syncFile = false): void
{
    $directory = dirname($path);
    $realDirectory = realpath($directory);

    if ($realDirectory === false || !is_dir($realDirectory) || !is_writable($realDirectory)) {
        throw new RuntimeException('Destination directory is not writable');
    }

    $temporary = tempnam($realDirectory, '.replace-');
    if ($temporary === false) {
        throw new RuntimeException('Could not create a temporary file');
    }

    // tempnam() may fall back to the system temp directory.
    if (realpath(dirname($temporary)) !== $realDirectory) {
        @unlink($temporary);
        throw new RuntimeException('Temporary file was created on another path');
    }

    $handle = null;

    try {
        $handle = fopen($temporary, 'wb');
        if ($handle === false) {
            throw new RuntimeException('Could not open the temporary file');
        }

        $length = strlen($data);
        $offset = 0;

        while ($offset < $length) {
            $written = fwrite($handle, substr($data, $offset));
            if ($written === false || $written === 0) {
                throw new RuntimeException('Could not write the complete file');
            }
            $offset += $written;
        }

        if (!fflush($handle)) {
            throw new RuntimeException('Could not flush the temporary file');
        }

        if ($syncFile) {
            if (!function_exists('fsync') || !fsync($handle)) {
                throw new RuntimeException('Could not synchronize the temporary file');
            }
        }

        if (!fclose($handle)) {
            throw new RuntimeException('Could not close the temporary file');
        }
        $handle = null;

        if (!rename($temporary, $path)) {
            throw new RuntimeException('Could not replace the destination');
        }
    } finally {
        if (is_resource($handle)) {
            fclose($handle);
        }
        if (is_file($temporary)) {
            @unlink($temporary);
        }
    }
}

The location check is not decorative. The official tempnam() documentation says PHP may create the file in the system temporary directory when the requested directory does not exist or is not writable. POSIX permits a cross-filesystem rename to fail with EXDEV, and Linux also rejects renames across mount points. Creating the temporary file beside the destination keeps the normal operation on one mounted filesystem.

The helper also checks for zero progress in the write loop. A loop that retries forever when fwrite() returns zero can turn an I/O failure into a stuck request. The function throws instead, and the finally block cleans up what it can.

PHP's rename() manual says an existing destination file is overwritten and reports success as true. The code still checks that result because permissions, a full filesystem, a read-only mount, or an I/O error can make the operation fail. Suppressing that failure would convert a recoverable old file into a falsely reported successful update.

When a lock file belongs around the replacement

For independent snapshots where “last completed writer wins” is acceptable, atomic replacement may be enough. For a counter, a queue, or any read-modify-write operation, it is not. The lock must begin before reading the current state and remain held until the rename succeeds.

$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
    throw new RuntimeException('Could not acquire the update lock');
}

try {
    $current = is_file($path) ? file_get_contents($path) : '{}';
    if ($current === false) {
        throw new RuntimeException('Could not read the current file');
    }

    $next = buildNextDocument($current);
    atomicReplace($path, $next, true);
} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}

A separate, stable .lock path is important. Locking the destination inode and then replacing that destination changes which inode the path names. Other writers could subsequently lock the new file while the first process still holds a lock on the old one. A dedicated lock file gives all cooperating writers the same rendezvous point.

Limits that should stay visible

  • Platform semantics differ. The example deliberately targets POSIX/Linux. PHP documents Windows-specific constraints for replacing an existing destination; do not claim identical behavior without a Windows design and test.
  • Network filesystems need separate analysis. The Linux manual notes an NFS ambiguity: a server may complete a rename and then fail before the client receives the result. Retrying can report failure even though the name changed. This helper does not solve distributed filesystem semantics.
  • Permissions can change. tempnam() creates its file with mode 0600. After replacement, that becomes the destination's mode. If another user or group must read the file, set and verify the intended ownership and mode before publishing it, under a carefully controlled policy.
  • Paths still need trust boundaries. The helper assumes the destination path and parent directory are controlled by the application. It is not a defense against an attacker who can rewrite directory entries or supply arbitrary paths.
  • Atomic does not mean validated. Validate or encode the complete document before calling the helper. Atomic replacement can publish invalid JSON perfectly.
  • High write volume may call for a database. Locks, temporary files, sync operations, recovery rules, and multiple records are features databases already organize. A filesystem snapshot is useful partly because its scope is small.

How to test the failure paths

A happy-path test should verify the exact resulting bytes and confirm no .replace-* file remains. More revealing tests make the directory unwritable, pass a missing parent, fill a small test filesystem, run several cooperating writers, and deliberately throw before the rename. After each failed attempt, check that the old destination is still intact.

Concurrency tests should distinguish two questions. First, did any reader observe malformed or partial content? Second, did the application preserve every logical update? Atomic rename addresses the first; the stable writer lock or a transactional data store addresses the second. Crash-durability testing is another category again and should match the actual filesystem and storage stack rather than a development laptop simulation.

Conclusion

Safe whole-file replacement is less about finding a magical write flag and more about naming the guarantee required. Write a unique temporary file in the destination directory, check every write, close it, and rename it over the published path to keep partial content out of ordinary readers. Add a stable advisory lock when writers must serialize. Add synchronization and platform-specific work only when the durability requirement justifies it.

This pattern is a good fit for modest, replaceable snapshots. Once the file becomes a multi-writer data model, a queue, or a source of critical durable state, the more useful question may be whether it should remain a file at all.

References