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"), and 418.0 was written as 418. 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 one null; 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.