Most of my PHP scripts run from cron with nobody watching, so they need a way to remember things between runs. A database is overkill for “did I already send that email?”, so I keep a small JSON file next to the script. It works well, but there are a few gotchas. Here is the lot, all tested on PHP 8.2 on my own server.
Reading a JSON file in PHP
Two functions. file_get_contents() reads the file into a string and json_decode() turns it into an array (the true is what makes it an array rather than an object).
<?php
$data = json_decode(file_get_contents('data.json'), true);
echo $data['name'];
That’s all you need if the file is always there and always valid. It never is, of course!
Writing a JSON file in PHP
The other way round: json_encode() then file_put_contents(). Add JSON_PRETTY_PRINT while you’re testing so you can read the file in a text editor.
<?php
$data = [
'name' => 'Alice',
'email' => '[email protected]',
'skills' => ['HTML', 'CSS', 'JavaScript'],
];
file_put_contents('data.json', json_encode($data, JSON_PRETTY_PRINT));
Two flags are worth knowing about. Without JSON_UNESCAPED_UNICODE, a name like café is saved as caf\u00e9, and without JSON_UNESCAPED_SLASHES every a/b becomes a\/b. Both are still valid JSON, but it makes the file harder to read when you’re checking it by eye.
When the file is missing or broken
This is the part most guides skip. I tried three kinds of broken file to see what PHP does with them:
- An empty file (0 bytes)
- A trailing comma, like
{"a": 1,} - Single quotes instead of double quotes, like
{'a': 1}
All three give you exactly the same message from json_last_error_msg(), which is just Syntax error. It doesn’t tell you where. In practice I open the file and look for the comma.
There’s a fourth one that caught me out. Some Windows editors save UTF-8 files with an invisible byte order mark (BOM) at the start. The file looks perfect on screen, but json_decode() returns null and again says Syntax error. If you edit your JSON on a PC and upload it, strip the BOM when you read it.
So this is the function I actually use for reading. It returns a default if the file is missing or unreadable, and writes the reason to the error log, so the script carries on rather than falling over:
<?php
function load_json(string $file, array $default = []): array
{
if (!is_file($file)) {
return $default;
}
$raw = ltrim(file_get_contents($file), "\xEF\xBB\xBF");
$data = json_decode($raw, true);
if (!is_array($data)) {
error_log("Could not read $file: " . json_last_error_msg());
return $default;
}
return $data;
}
Updating a JSON file safely
The obvious way to change a JSON file is to read it, change the array, and write it back. That’s fine for one script running once an hour. It is not fine if two things can run at the same time.
To see how bad it gets I ran 4 copies of a script at once, each adding 250 entries to the same file with the plain read-change-write method. I should have ended up with 1,000 entries. I got 16! The rest were overwritten by whichever copy wrote last.
The fix is flock(), which makes the other copies wait their turn. I ran exactly the same test with it and got all 1,000 entries, every time. This is the function:
function update_json(string $file, callable $change): void
{
$fp = fopen($file, 'c+');
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$data = $raw === '' ? [] : (json_decode($raw, true) ?? []);
$data = $change($data);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
}
You give it the file and a function that changes the data. For example, a tiny logger (and yes, it will create log.json if it doesn’t exist yet):
update_json('log.json', function ($log) {
$log[] = [
'time' => date('Y-m-d H:i:s'),
'type' => 'info',
'message' => 'New user registered.',
];
return $log;
});
How big can the file get?
Every time you add an entry, PHP reads and rewrites the whole file, so it gets slower as it grows. I timed it with entries like the one above (about 76 bytes each):
| Entries | File size | Time per append |
|---|---|---|
| 1,000 | 76 KB | 1 ms |
| 10,000 | 762 KB | 9 ms |
| 50,000 | 3.8 MB | 59 ms |
That’s on a VPS with the file in the page cache, so your host may well be slower. Up to a few thousand entries I wouldn’t worry. Beyond that, either trim the old entries or use a database. For a state file that only ever holds a handful of values (my favourite use), it’s a non-issue.
What I use it for
Two of my cron scripts do this (they’re written in Python, but the idea is identical). The first checks whether a stream is up and emails me if it isn’t. Without a memory it would email me every time it ran while the stream was down. So it keeps a three-line JSON file with the status, when it went down and whether it has already told me. The second remembers the last artist and title it picked up, so it doesn’t post the same song twice. Neither needs a database.
Next time your script needs a memory, try a JSON file first. And test it with an empty one before you trust it!