← Blog/23 October 2026 · 4 min read

A JSON file and a lock: when a small PHP app doesn't need a database

For an app with a few hundred users, a single JSON file guarded by flock and written atomically is simpler and safer than you might think. Here is the pattern, why each line is there, and the point at which we would move to MySQL.

PHPArchitectureStorageShared hosting

Every new web app seems to begin with the same ritual: create a database, write a schema, set up migrations, store credentials somewhere. For a product that may end up with a few hundred users, that ritual is often the most complicated part of the whole system.

For one of our internal tools we skipped it. All state lives in one JSON file, read and written by plain PHP. It has run without a corrupted write since launch. This post explains the pattern and, just as importantly, its limits.

The whole storage layer

function load(): void {
  global $db, $lock, $DATA;
  if ($lock) return;
  $lock = fopen("$DATA/app.lock", 'c');
  flock($lock, LOCK_EX);
  $db = json_decode(@file_get_contents("$DATA/app.json") ?: '{}', true) ?: [];
  $db += ['users' => [], 'sessions' => [], 'ledger' => []];
}

function save(): void {
  global $db, $DATA;
  file_put_contents("$DATA/app.json.tmp", json_encode($db));
  rename("$DATA/app.json.tmp", "$DATA/app.json");
}

That is all of it. Every request that touches state calls load() first and save() after changing something. Let us go through why each piece is there.

The lock: one writer at a time

Two requests arriving at the same moment are the classic way to lose data with files. Request A reads the file, request B reads the same file, A adds a point to a user's balance and writes, then B writes its own copy, which never saw A's change. A's update is gone.

flock($lock, LOCK_EX) takes an exclusive lock on a separate lock file. Any other request calling load() waits on that line until the first one finishes. PHP releases the lock automatically when the request ends, even if it crashes halfway, so a failed request cannot leave the app stuck.

We lock a separate app.lock file rather than the data file itself because the data file is about to be replaced (see below), and a lock on a file that has been swapped out from under you protects nothing.

Note that the lock covers the entire read, modify, write cycle, not just the write. Locking only around the write would still let two requests read the same old state.

The atomic write: never a half-written file

file_put_contents on the real file would truncate it and then write. If the server dies between those two steps, or the disk fills up, you are left with an empty or half-written file and every user is gone.

Instead we write the complete new contents to app.json.tmp and then rename() it over the old file. On the same filesystem, rename is atomic: at every instant, app.json is either the complete old version or the complete new version, never anything in between. This one line is the difference between a toy and something you can trust with real data.

Keeping it private

A JSON file in a web-served folder is a JSON file anyone can download unless you say otherwise. The data folder has its own .htaccess:

Require all denied

And the app's main .htaccess refuses the folder and the config file by pattern as a second layer, in case the inner file is ever lost during a deploy. We check this after every deploy by requesting the file from outside and expecting a 403.

What it costs

The lock means requests that touch state run one at a time. For our tool, a request holds the lock for a millisecond or two: parse a few hundred kilobytes of JSON, change a value, encode and write it back. That is hundreds of state-changing requests per second, far more than the app will ever see.

The busiest endpoint, a heartbeat each open tab sends every few seconds, reads state but never writes it. The "this user has a tab open" signal it records is not stored in the JSON at all: it updates the modification time of a small per-user file with touch(), so the heartbeat never pays for encoding and rewriting the whole file.

Where it stops working

We wrote the limit down in a comment next to the code, so the next person knows when to change it:

  • File size. Every request reads and writes the whole file. At a few megabytes, the parse and encode time starts to matter, and the lock is held for longer.
  • Contention. If many requests write at once, they queue behind the lock. On a busy site that queue becomes your latency.
  • Queries. Anything like "top 10 users this month" means scanning everything in PHP. That is fine for hundreds of records and silly for hundreds of thousands.
  • More than one server. flock only coordinates processes on one machine. The moment you add a second web server, this pattern is wrong.

Our rule of thumb: past a few thousand users, or the first time we need a second server, it moves to MySQL. Because all storage access goes through load() and save(), that migration touches one small part of the code.

Why not SQLite?

SQLite is the obvious middle ground, and on a server where the pdo_sqlite extension is available it is a fine choice. We chose JSON because the data is tiny, the whole state is readable in any text editor during debugging, and a backup is a single file copy. When we outgrow JSON we expect to skip straight to the managed MySQL that the hosting plan already includes.

Summary

A database is a tool for problems you have, not a ritual you perform. For a small app on a single server, a JSON file with an exclusive lock around each read-modify-write cycle and an atomic rename on every save is correct, fast enough and easy to inspect. Write down when it stops being enough, and keep storage behind two functions so that day is a small change rather than a rewrite.

Next article
Sign in with Google in plain PHP: the checks that actually matter →