Working with Files and JSON
Reading and writing data in PHP: files with file_get_contents and fopen, paths and directories, CSV, JSON encoding and decoding, and dates with DateTimeImmutable.
Reading and writing whole files
Most files a PHP application touches are small: a configuration
file, an export, a log line, a cached API response. For those, two
functions do all the work.
file_get_contents() returns the complete file as one
string, and file_put_contents() writes a string to a
file, creating it or replacing what was there. It returns the number
of bytes written. The flag FILE_APPEND adds to the end
instead, and LOCK_EX holds an exclusive lock while
writing (see locking files).
file() reads a file into an array of lines.
<?php
$log = 'readings.log';
$bytes = file_put_contents($log, "KE-4471;2026-10-01;412.5\n");
var_dump($bytes);
file_put_contents($log, "KE-4471;2026-10-02;418.0\n", FILE_APPEND | LOCK_EX);
echo file_get_contents($log);
$lines = file($log, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
echo count($lines), " lines, the last one is ", $lines[count($lines) - 1], "\n";
int(25) KE-4471;2026-10-01;412.5 KE-4471;2026-10-02;418.0 2 lines, the last one is KE-4471;2026-10-02;418.0
PHP strings are byte strings, so these functions work for text and
binary data alike; PHP does not convert line endings or character
encodings when it reads. Text files you exchange should be UTF-8
(see
UTF-8).
For files of hundreds of megabytes, reading everything at once costs
as much memory as the file is large and can hit
memory_limit; read those
line by line instead.
Checking for failure
File functions predate exceptions in PHP. When a file is missing or
not readable, file_get_contents() emits a warning and
returns false, and the script carries on. If you do not
check, false silently becomes an empty string further
on. Compare with === false: a file can be empty, and
"" is falsy too.
<?php
$text = file_get_contents('tariffs.json');
var_dump($text);
if ($text === false) {
echo "Could not read tariffs.json\n";
}
bool(false) Could not read tariffs.json
Warning: file_get_contents(tariffs.json): Failed to open stream: No such file or directory in /var/www/kestrel/import.php on line 2
In production that warning goes to the
error log,
which is useful; resist hiding it with the @
operator. A common pattern is to check the result and throw an
exception with a clear message, as the
settings loader below does. Checking first
with is_readable() helps for a friendly message, but
the file can still disappear between the check and the read, so keep
the === false test as well.
File handles: fopen, fgets and fclose
For more control, open the file with fopen(). It
returns a file handle: a value of
PHP's resource type that stands for the open file and
remembers a position in it. Functions such as
fgets() (read one line), fread() (read a
number of bytes) and fwrite() work on the handle and
move the position forward; fclose()
releases it. Reading line by line keeps only one line in memory,
however large the file.
<?php
file_put_contents('readings.txt', "412.5\n418.0\n\n421.25\n");
$handle = fopen('readings.txt', 'r');
if ($handle === false) {
exit("Cannot open readings.txt\n");
}
$total = 0.0;
$count = 0;
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue; // skip empty lines
}
$total += (float) $line;
$count++;
}
fclose($handle);
printf("%d readings, %.2f kWh in total\n", $count, $total);
3 readings, 1251.75 kWh in total
fgets() keeps the line ending, hence the
trim(). It returns false at the end of the
file, so the loop compares with !== false: a last line
of just "0" with no newline after it would end a loose
while ($line = fgets($handle)) early.
The second argument of fopen() is the mode:
| Mode | Opens for | If the file exists | If it does not |
|---|---|---|---|
r / r+ |
reading / reading and writing | starts at the beginning | fails |
w / w+ |
writing / writing and reading | empties it | creates it |
a / a+ |
appending / appending and reading | writes always go to the end | creates it |
x / x+ |
writing / writing and reading | fails | creates it |
c / c+ |
writing / writing and reading | keeps the content, starts at the beginning | creates it |
A b added to the mode (rb,
wb) means binary; on Linux it changes nothing, on
Windows it prevents line-ending translation, so it is a harmless
habit for portable code. Mode x is useful when two
processes must never write the same file: only one of them can
create it.
Locking files
A web server runs many PHP requests at the same time. When two of
them update the same file, one can overwrite the other's change, or
a reader can see a half-written file. A
file lock makes them take turns:
flock($handle, LOCK_EX) waits until no other process
holds a lock on the file, and LOCK_SH takes a shared
lock for readers. The lock is released with LOCK_UN or
when the handle is closed.
<?php
file_put_contents('counter.txt', '41');
function next_ticket_number(string $file): int
{
$handle = fopen($file, 'c+');
if ($handle === false) {
throw new RuntimeException("Cannot open $file");
}
try {
flock($handle, LOCK_EX);
$number = (int) stream_get_contents($handle) + 1;
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, (string) $number);
fflush($handle);
flock($handle, LOCK_UN);
return $number;
} finally {
fclose($handle);
}
}
echo next_ticket_number('counter.txt'), "\n";
echo next_ticket_number('counter.txt'), "\n";
echo file_get_contents('counter.txt'), "\n";
42 43 43
Mode c+ opens the file without emptying it, so the
current number can be read after the lock is taken; mode
w would already have erased it. Two points to keep in
mind: on Linux these locks are advisory (they only stop
programs that also call flock()), and they are not
reliable on network file systems such as NFS. For counters and
anything shared by many requests, a
database with transactions is
usually the better tool.
Paths and directories
A relative path such as data/settings.json is resolved
against the
working directory
of the process, not against the folder of the script. Under a web
server that is usually the folder of the requested script; on the
command line it is wherever the command was started, and a cron job
often starts in the user's home folder. The magic constant
__DIR__ always holds the folder of the file it appears
in, so paths built from it work everywhere:
<?php
// src/Import/MeterImport.php wants storage/imports/today.csv two levels up
$file = __DIR__ . '/../../storage/imports/today.csv';
// dirname() with a level count does the same without "..":
$root = dirname(__DIR__, 2);
$file = $root . '/storage/imports/today.csv';
Three functions take paths apart without touching the disk:
<?php
$file = '/var/www/kestrel/storage/exports/readings-2026-10.csv';
echo basename($file), "\n";
echo dirname($file), "\n";
echo pathinfo($file, PATHINFO_FILENAME), "\n";
echo pathinfo($file, PATHINFO_EXTENSION), "\n";
readings-2026-10.csv /var/www/kestrel/storage/exports readings-2026-10 csv
Forward slashes work on every operating system PHP runs on,
including Windows, so you rarely need the constant
DIRECTORY_SEPARATOR.
Working with folders
mkdir() creates a folder (with true as the
third argument also its parents), glob() lists the
paths that match a wildcard pattern, and
scandir() lists every name in a folder, including
. and ... Both return their results
sorted. is_file(), is_dir() and
file_exists() test what is there, and
unlink() deletes a file (rmdir() an empty
folder).
<?php
$folder = 'exports/2026/10';
if (!is_dir($folder)) {
mkdir($folder, 0775, true); // true: create the parent folders too
}
file_put_contents("$folder/readings-north.csv", "meter,kwh\n");
file_put_contents("$folder/readings-south.csv", "meter,kwh\n");
file_put_contents("$folder/notes.txt", "Checked by Amara\n");
print_r(glob("$folder/*.csv"));
print_r(array_values(array_diff(scandir($folder), ['.', '..'])));
var_dump(is_file("$folder/notes.txt"), file_exists($folder), is_file($folder));
echo filesize("$folder/notes.txt"), " bytes\n";
unlink("$folder/notes.txt");
var_dump(file_exists("$folder/notes.txt"));
Array
(
[0] => exports/2026/10/readings-north.csv
[1] => exports/2026/10/readings-south.csv
)
Array
(
[0] => notes.txt
[1] => readings-north.csv
[2] => readings-south.csv
)
bool(true)
bool(true)
bool(false)
17 bytes
bool(false)
The mode 0775 is octal, like in chmod, and
the process's umask still removes bits from it (see
umask). Files PHP creates belong to the user PHP runs as, often
www-data; that user needs write permission on the
folders it writes to and nothing more (see
file layout and permissions).
CSV files
CSV is
still how meter readings, bank statements and spreadsheet exports
travel. Do not split lines on commas yourself: a field may contain a
comma, a quote or even a line break inside quotes.
fputcsv() writes one row with the quoting done for you,
and fgetcsv() reads one row back as an array.
<?php
$rows = [
['meter', 'date', 'kwh', 'note'],
['KE-4471', '2026-10-01', 412.5, ''],
['KE-4471', '2026-10-02', 418.0, 'estimated, "photo unclear"'],
['KE-5120', '2026-10-02', 97.25, ''],
];
$out = fopen('readings.csv', 'w');
foreach ($rows as $row) {
fputcsv($out, $row, escape: '');
}
fclose($out);
echo file_get_contents('readings.csv'), "\n";
$in = fopen('readings.csv', 'r');
$header = fgetcsv($in, escape: '');
$total = 0.0;
while (($row = fgetcsv($in, escape: '')) !== false) {
$reading = array_combine($header, $row);
$total += (float) $reading['kwh'];
echo "{$reading['meter']} on {$reading['date']}: {$reading['kwh']} kWh";
echo $reading['note'] === '' ? "\n" : " ({$reading['note']})\n";
}
fclose($in);
echo "Total: $total kWh\n";
var_dump($reading['kwh']);
meter,date,kwh,note KE-4471,2026-10-01,412.5, KE-4471,2026-10-02,418,"estimated, ""photo unclear""" KE-5120,2026-10-02,97.25, KE-4471 on 2026-10-01: 412.5 kWh KE-4471 on 2026-10-02: 418 kWh (estimated, "photo unclear") KE-5120 on 2026-10-02: 97.25 kWh Total: 927.75 kWh string(5) "97.25"
Some details from this example:
-
Every value read from a CSV file is a string (the
last line shows
"97.25"), and418.0was written as418. Convert and validate each field as you would form input. -
array_combine()with the header row turns each row into an associative array, so the code reads$reading['kwh']instead of$row[2]. -
escape: ''switches off PHP's own backslash escape character, which is not part of the CSV standard (RFC 4180) and can corrupt fields that end in a backslash. Since PHP 8.4, leaving the argument out is deprecated, so pass it explicitly; the named argument keeps the call short. -
An empty line in the file comes back as
[null], an array with onenull; skip such rows when the input may contain them.
Spreadsheet programs with a Dutch or German locale save CSV with a semicolon and a decimal comma. Pass the separator, and convert the number yourself:
<?php
print_r(str_getcsv('KE-4471;2026-10-01;"412,5"', ';', '"', ''));
Array
(
[0] => KE-4471
[1] => 2026-10-01
[2] => 412,5
)
JSON
JSON is
the format of APIs, configuration files and data that JavaScript in
the browser must read. PHP's JSON support is built in (the json
extension cannot be disabled since PHP 8.0) and maps JSON onto PHP
values in a predictable way: JSON objects become associative arrays
or objects, JSON lists become lists, and numbers, strings, booleans
and null map onto their PHP equivalents.
Encoding: json_encode()
json_encode()
turns a PHP value into a JSON string. Its defaults are cautious and
compact: no spaces, slashes escaped as \/, and every
non-ASCII character written as a \u escape. Flags
change that, and JSON_THROW_ON_ERROR makes it throw a
JsonException instead of returning false,
for example for a string that is not valid UTF-8.
<?php
$outage = [
'ticket' => 'INC-31877',
'area' => 'Den Haag/Scheveningen',
'reported_by' => 'Café De Pier',
'customers' => 1240,
'restored' => false,
'mwh_lost' => 3.0,
'causes' => ['cable damage', 'excavation'],
'confirmed' => null,
];
echo json_encode($outage), "\n\n";
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
| JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR;
echo json_encode($outage, $flags), "\n";
{"ticket":"INC-31877","area":"Den Haag\/Scheveningen","reported_by":"Caf\u00e9 De Pier","customers":1240,"restored":false,"mwh_lost":3,"causes":["cable damage","excavation"],"confirmed":null}
{
"ticket": "INC-31877",
"area": "Den Haag/Scheveningen",
"reported_by": "Café De Pier",
"customers": 1240,
"restored": false,
"mwh_lost": 3.0,
"causes": [
"cable damage",
"excavation"
],
"confirmed": null
}
Both outputs are the same data; the second is easier to read in a
file. Note "mwh_lost":3 in the first: a float without a
fractional part loses its .0 unless you pass
JSON_PRESERVE_ZERO_FRACTION. For a reader in JavaScript
that makes no difference, but a strictly typed client may treat
3 as an integer. Keep the slash escaping when the JSON
goes inside an HTML <script> block, together with
the JSON_HEX_* flags (see
other output contexts).
Lists or objects?
A
PHP array can be
either a JSON list or a JSON object.
json_encode() writes a list only when the keys are
exactly 0, 1, 2, … in order; any other array becomes an
object. That catches people after array_filter(), which
keeps the original keys:
<?php
echo json_encode([]), "\n"; // empty array: a JSON list
echo json_encode(['KE-4471', 'KE-5120']), "\n"; // keys 0, 1: a list
echo json_encode(['meter' => 'KE-4471']), "\n"; // string keys: an object
$meters = ['KE-4471', 'KE-0000', 'KE-5120'];
$valid = array_filter($meters, fn($m) => $m !== 'KE-0000');
echo json_encode($valid), "\n"; // keys 0 and 2: an object!
echo json_encode(array_values($valid)), "\n"; // renumbered: a list again
echo json_encode([], JSON_FORCE_OBJECT), "\n"; // an empty object
[]
["KE-4471","KE-5120"]
{"meter":"KE-4471"}
{"0":"KE-4471","2":"KE-5120"}
["KE-4471","KE-5120"]
{}
An API client in JavaScript that expects a list and receives
{"0":…,"2":…} breaks on .map(). Renumber
with array_values() before encoding. The opposite
problem exists too: an empty array is always [], so an
object that happens to be empty needs
JSON_FORCE_OBJECT or an object such as
new stdClass().
Decoding: json_decode()
json_decode()
parses a JSON string into PHP values. By default JSON objects become
objects of the built-in class stdClass, read with
->; with true as the second argument
they become associative arrays, which most code finds easier to work
with.
<?php
$json = '{"ticket":"INC-31877","customers":1240,"restored":false,"location":{"city":"Den Haag","postcode":"2586"}}';
$asObject = json_decode($json);
echo get_class($asObject), ': ', $asObject->location->city, "\n";
$asArray = json_decode($json, true);
echo gettype($asArray), ': ', $asArray['location']['city'], "\n";
var_dump($asArray['customers'], $asArray['restored']);
stdClass: Den Haag array: Den Haag int(1240) bool(false)
Without a flag, invalid JSON makes json_decode() return
null, and you have to ask
json_last_error_msg() what went wrong. That is
ambiguous, because the valid JSON text null also
decodes to null. With
JSON_THROW_ON_ERROR a mistake becomes an exception you
cannot overlook:
<?php
// Without the flag: null for broken JSON, but also for valid JSON "null"
var_dump(json_decode('{"ticket": "INC-31877",}'));
echo json_last_error_msg(), "\n";
var_dump(json_decode('null'));
echo json_last_error_msg(), "\n";
// With JSON_THROW_ON_ERROR: an exception you cannot overlook
try {
$data = json_decode('{"ticket": "INC-31877",}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
NULL Syntax error NULL No error JsonException: Syntax error
JSON files
Reading and writing a JSON file combines the pieces above. Decoded data from a file is input like any other: check that it has the shape you expect before you use it.
<?php
// Write the settings once (normally they are already there)
$settings = ['region' => 'Zuid-Holland', 'alert_threshold_kwh' => 500, 'notify' => ['noc@example.com']];
file_put_contents('settings.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n");
function load_settings(string $file): array
{
$text = file_get_contents($file);
if ($text === false) {
throw new RuntimeException("Cannot read $file");
}
$data = json_decode($text, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new UnexpectedValueException("$file does not contain a JSON object");
}
return $data;
}
$settings = load_settings('settings.json');
$settings['notify'][] = 'billing@example.com';
file_put_contents('settings.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n", LOCK_EX);
echo file_get_contents('settings.json');
{
"region": "Zuid-Holland",
"alert_threshold_kwh": 500,
"notify": [
"noc@example.com",
"billing@example.com"
]
}
Objects can decide how they are encoded by implementing the
JsonSerializable interface:
json_encode() then calls their
jsonSerialize() method. Decoding never creates objects
of your own classes, which is why JSON is the safe choice for data
from outside (see
unserialize, eval and shell commands).
Dates and times with DateTimeImmutable
PHP's date handling is object-oriented.
DateTimeImmutable
represents one moment in time together with a time zone; methods
such as modify(), add() and
setTimezone() return a new object and leave
the original untouched. format() turns it into text
with format letters: Y-m-d H:i for
2026-10-02 14:30, the constant DATE_ATOM
for
ISO 8601.
<?php
$amsterdam = new DateTimeZone('Europe/Amsterdam');
$reported = new DateTimeImmutable('2026-10-02 14:30', $amsterdam);
echo $reported->format('Y-m-d H:i T'), "\n";
echo $reported->format(DATE_ATOM), "\n";
echo $reported->format('l j F Y'), "\n";
$deadline = $reported->modify('+3 days'); // a new object
echo $deadline->format('Y-m-d H:i'), "\n";
echo $reported->format('Y-m-d H:i'), "\n"; // unchanged
$utc = $reported->setTimezone(new DateTimeZone('UTC'));
echo $utc->format(DATE_ATOM), "\n";
echo $reported->getTimestamp() === $utc->getTimestamp() ? "same moment\n" : "different\n";
2026-10-02 14:30 CEST 2026-10-02T14:30:00+02:00 Friday 2 October 2026 2026-10-05 14:30 2026-10-02 14:30 2026-10-02T12:30:00+00:00 same moment
Always pass a DateTimeZone or put the offset in the
string. Without one, PHP uses the date.timezone setting
from php.ini, which may be UTC on one server and
Europe/Amsterdam on the next. Use region names such as
Europe/Amsterdam rather than fixed offsets like
+02:00, so daylight saving time is handled for you.
Why immutable
The older class DateTime has the same methods, but they
change the object itself. Code that calculates a due date from a
start date then quietly moves the start date as well:
<?php
$start = new DateTime('2026-10-01');
$end = $start->modify('+1 month'); // DateTime changes itself and returns itself
echo $start->format('Y-m-d'), ' ', $end->format('Y-m-d'), "\n";
$start = new DateTimeImmutable('2026-10-01');
$end = $start->modify('+1 month');
echo $start->format('Y-m-d'), ' ', $end->format('Y-m-d'), "\n";
2026-11-01 2026-11-01 2026-10-01 2026-11-01
Use DateTimeImmutable for new code. When a library
hands you a DateTime,
DateTimeImmutable::createFromMutable() converts it;
both classes implement the DateTimeInterface interface,
which is the type to accept in a function that takes either.
Calendar arithmetic
modify() understands phrases such as
+1 day, next monday and
last day of next month. Calendar arithmetic follows the
clock on the wall, which has two consequences worth seeing once:
<?php
$tz = new DateTimeZone('Europe/Amsterdam');
// Daylight saving time ends on 25 October 2026
$before = new DateTimeImmutable('2026-10-24 12:00', $tz);
$after = $before->modify('+1 day');
echo $before->format('Y-m-d H:i P'), ' -> ', $after->format('Y-m-d H:i P'), "\n";
echo ($after->getTimestamp() - $before->getTimestamp()) / 3600, " hours later\n";
// Adding a month to 31 January overflows into March
$billing = new DateTimeImmutable('2026-01-31');
echo $billing->modify('+1 month')->format('Y-m-d'), "\n";
echo $billing->modify('last day of next month')->format('Y-m-d'), "\n";
// The difference between two dates
$installed = new DateTimeImmutable('2019-06-15');
$checked = new DateTimeImmutable('2026-10-02');
$age = $installed->diff($checked);
echo $age->y, ' years, ', $age->m, ' months; ', $age->days, " days in total\n";
2026-10-24 12:00 +02:00 -> 2026-10-25 12:00 +01:00 25 hours later 2026-03-03 2026-02-28 7 years, 3 months; 2666 days in total
On the day daylight saving time ends, "+1 day" is 25 hours. Adding a
month to 31 January asks for 31 February, which PHP rolls over into
March; for billing periods use phrases like
last day of next month, or start from the first day of
the month. diff() returns a
DateInterval with the calendar parts (y,
m, d) and the total number of
days.
Parsing and storing dates
The constructor accepts many formats, which is convenient for fixed
formats such as ISO 8601 but risky for user input:
02-10-2026 is read as 2 October, but
02/10/2026 as 10 February: with slashes PHP assumes the
American month/day order, so a British 02/10/2026 is
read as 10 February too. For a known format, use
createFromFormat(), which returns false
when the input does not match. It does not reject impossible dates:
31-02-2026 matches the format and becomes 3 March. To
catch that, check
DateTimeImmutable::getLastErrors() (false
or zero warnings) or compare format('d-m-Y') with the
input:
<?php
$tz = new DateTimeZone('Europe/Amsterdam');
// '!' resets the fields the format does not mention (otherwise: the current time)
$date = DateTimeImmutable::createFromFormat('!d-m-Y', '02-10-2026', $tz);
echo $date->format('Y-m-d H:i:s'), "\n";
// Invalid input returns false instead of throwing
var_dump(DateTimeImmutable::createFromFormat('!d-m-Y', '31-02-2026x', $tz));
// Store and exchange in UTC, ISO 8601; convert for display
$stored = '2026-10-02T12:30:00+00:00';
$shown = (new DateTimeImmutable($stored))->setTimezone($tz);
echo $shown->format('d-m-Y H:i'), "\n";
2026-10-02 00:00:00 bool(false) 02-10-2026 14:30
Without the !, every field the format does not mention
(here the time) is filled in from the current moment, so the same
line gives a different result on each run. Store moments in UTC (a
database TIMESTAMP or an ISO 8601 string with offset)
and convert to the user's time zone only when you display them. The
Python equivalent, the datetime
module, is described in
the standard library.