Writing, Appending and Locking

file_put_contents($path, $data, FILE_APPEND | LOCK_EX) opens, locks, appends and closes in one call. With a handle, the fopen() mode decides what happens:

fopen() modes; + adds reading to a write mode, or writing to r
Mode Existing file Missing file Position
r (read only), r+ kept error start
w, w+ truncated created start
a, a+ kept created end, for every write
x, x+ error created start
c, c+ kept created start

x is an atomic "create only if absent"; c does not truncate, so you can lock before emptying the file, which w does on open. This script increments a shared counter:

counter.php: read, increment and rewrite a shared counterPHP
<?php
declare(strict_types=1);
[, $mode, $times] = $argv;
for ($i = 0; $i < (int) $times; $i++) {
  $fp = fopen(__DIR__ . '/hits.txt', 'c+');
  if ($mode === 'lock') flock($fp, LOCK_EX);          // wait until the lock is ours
  $count = (int) stream_get_contents($fp);
  ftruncate($fp, 0); rewind($fp);
  fwrite($fp, (string) ($count + 1));
  fclose($fp);                                        // flushes, then releases the lock
}
Two concurrent processes, without and with flock()PHP
for mode in none lock; do
  echo 0 > hits.txt
  php counter.php $mode 5000 & php counter.php $mode 5000 &
  wait
  echo "$mode: expected 10000, got $(cat hits.txt)"
done
Output
none: expected 10000, got 475
lock: expected 10000, got 10000

Unlocked, a process often reads just after the other truncated, gets an empty string and restarts from zero. flock() locks are advisory: they bind only processes that also call flock(), and end with LOCK_UN, fclose() or exit. LOCK_SH lets readers share, and LOCK_EX | LOCK_NB fails at once instead of waiting, so a cron job can skip a run while the last is busy.