Free Handbook · Every example compiled & verified

Files, JSON & Dates

Read and write files, stream them line by line, handle CSV and directories, encode and decode JSON safely, and work with dates, intervals and time zones.

0 / 148 lessons🔥 0 day streak
ShareXLinkedIn

Module 09 · what you'll be able to do

  • Read and write whole files with file_get_contents/file_put_contents and stream large ones with fopen, fgets and SplFileObject
  • Write and parse CSV with fputcsv/fgetcsv, and list, create and remove directories
  • Encode JSON with the right flags and decode it into arrays or objects with JSON_THROW_ON_ERROR
  • Create, format and change dates with DateTimeImmutable, and measure gaps with DateInterval and diff()
  • Store times in UTC, convert them to a user's time zone, and avoid the month-end and DST traps
01

Reading and writing whole files

For small files, two functions do everything. file_put_contents($path, $data) creates or overwrites a file and returns the number of bytes written; add FILE_APPEND to add to the end and LOCK_EX so two requests cannot interleave their writes. file_get_contents($path) returns the whole file as a string. file($path) returns an array of lines. All of them return false (plus a warning) on failure, so compare with === false — see Module 08.

phpmain.php
<?php
$dir = sys_get_temp_dir() . '/hb-files-' . getmypid();
mkdir($dir);
$path = "$dir/notes.txt";

$bytes = file_put_contents($path, "first line\n");
echo "wrote $bytes bytes\n";
file_put_contents($path, "second line\nthird line\n", FILE_APPEND | LOCK_EX);

echo file_get_contents($path);
echo "size: ", filesize($path), " bytes\n";

$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
echo count($lines), " lines, last = '", end($lines), "'\n";

var_dump(is_file($path), is_file("$dir/missing.txt"));

unlink($path);                        // delete the file
rmdir($dir);                          // delete the (now empty) directory
var_dump(file_exists($dir));
Outputcompiled & run with real PHP
wrote 11 bytes
first line
second line
third line
size: 34 bytes
3 lines, last = 'third line'
bool(true)
bool(false)
bool(false)

sys_get_temp_dir() is the system temp folder; adding the process id keeps two runs from colliding. The program cleans up after itself.

Your turn

Write a function countWords(string $path): int that reads a file and returns str_word_count of its contents. Test it on a file you create in the same program.

Paths: __DIR__, dirname, basename, pathinfo
Relative paths are resolved against the current working directory, which is wherever the script was started from — not where the file lives. Build paths from __DIR__ (the directory of the current file) instead: __DIR__ . '/data/users.json'. basename(), dirname() and pathinfo() split a path into its parts.
phpmain.php
<?php
$p = '/var/www/app/storage/reports/2026-sales.final.csv';
echo basename($p), "\n";
echo basename($p, '.csv'), "\n";
echo dirname($p), "\n";
echo dirname($p, 2), "\n";            // two levels up
print_r(pathinfo($p));
Outputcompiled & run with real PHP
2026-sales.final.csv
2026-sales.final
/var/www/app/storage/reports
/var/www/app/storage
Array
(
    [dirname] => /var/www/app/storage/reports
    [basename] => 2026-sales.final.csv
    [extension] => csv
    [filename] => 2026-sales.final
)
02

Streaming with fopen, fgets and SplFileObject

file_get_contents loads the whole file into memory — fine for a config file, fatal for a 4 GB log. For big files open a stream with fopen($path, $mode) and read one line at a time with fgets(), which returns false at the end. Memory use stays flat no matter the file size. Always fclose() the handle (PHP closes it at the end of the script, but long-running workers must do it themselves). SplFileObject wraps the same thing in an object you can foreach over.

ModeReadWriteIf file existsIf missing
ryesnostart at the beginningfails
r+yesyesstart at the beginningfails
wnoyestruncated to zerocreated
anoyeswrites go to the endcreated
xnoyesfails (safe create)created
cnoyeskept, not truncatedcreated
phpmain.php
<?php
$path = tempnam(sys_get_temp_dir(), 'log');

$out = fopen($path, 'w');
foreach (['INFO boot', 'WARN slow query', 'INFO ready', 'ERROR db down', 'INFO retry'] as $line) {
    fwrite($out, $line . PHP_EOL);
}
fclose($out);

// Read line by line: constant memory
$in = fopen($path, 'r');
$counts = [];
while (($line = fgets($in)) !== false) {
    $level = strtok(rtrim($line), ' ');
    $counts[$level] = ($counts[$level] ?? 0) + 1;
}
fclose($in);
print_r($counts);

// The same with SplFileObject
$file = new SplFileObject($path);
$file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);
foreach ($file as $n => $line) {
    if (str_starts_with($line, 'ERROR')) {
        echo "line ", $n + 1, ": $line\n";
    }
}
$file = null;                          // releases the handle
unlink($path);
Outputcompiled & run with real PHP
Array
(
    [INFO] => 3
    [WARN] => 1
    [ERROR] => 1
)
line 4: ERROR db down

tempnam() creates an empty file with a unique name. PHP_EOL is the platform line ending. The flags make SplFileObject strip newlines and skip the empty last line.

Your turn

Change the reader to stop at the first ERROR line with break, and print how many lines it read.

Error you will hit

fgets(): Argument #1 ($stream) must be of type resource, false given

php
<?php
$handle = fopen('orders.csv', 'r');
while (($line = fgets($handle)) !== false) {
    echo $line;
}
fclose($handle);
Warning: fopen(orders.csv): Failed to open stream: No such file or directory in main.php on line 2

Fatal error: Uncaught TypeError: fgets(): Argument #1 ($stream) must be of type resource, false given in main.php:3
Stack trace:
#0 main.php(3): fgets(false)
#1 {main}
  thrown in main.php on line 3
Why PHP said that

Two messages, one bug. fopen could not find orders.csv (relative to the directory the script was started from), printed a warning and returned false. The code never checked, so fgets(false) threw a TypeError. Always read the first message first — it is the cause; the fatal error is the consequence.

The fix

Build the path from __DIR__ and check the handle before using it.

php
<?php
$path = __DIR__ . '/orders.csv';
$handle = is_file($path) ? fopen($path, 'r') : false;
if ($handle === false) {
    throw new RuntimeException("Cannot open $path");
}
while (($line = fgets($handle)) !== false) {
    echo $line;
}
fclose($handle);
03

CSV: fputcsv and fgetcsv

Never build CSV with implode(',', $row): the first value containing a comma, quote or newline breaks every column after it. fputcsv() quotes and escapes correctly, and fgetcsv() parses it back — including quoted fields with commas and newlines inside. Since PHP 8.4 you must pass the escape argument explicitly; pass escape: '', which gives standard RFC 4180 CSV (quotes are escaped by doubling them).

phpmain.php
<?php
$path = tempnam(sys_get_temp_dir(), 'csv');

$rows = [
    ['id', 'name', 'city', 'note'],
    [1, 'Asha', 'Pune', 'likes "tea"'],
    [2, 'Ravi', 'Delhi, NCR', ''],
    [3, 'Meera', 'Chennai', "two\nlines"],
];

$out = fopen($path, 'w');
foreach ($rows as $row) {
    fputcsv($out, $row, escape: '');
}
fclose($out);

echo file_get_contents($path), "---\n";

$in = fopen($path, 'r');
$header = fgetcsv($in, escape: '');
while (($row = fgetcsv($in, escape: '')) !== false) {
    $rec = array_combine($header, $row);          // ['id' => '1', 'name' => ...]
    printf("%s | %-5s | %-10s | %s\n", $rec['id'], $rec['name'], $rec['city'], json_encode($rec['note']));
}
fclose($in);
unlink($path);
Outputcompiled & run with real PHP
id,name,city,note
1,Asha,Pune,"likes ""tea"""
2,Ravi,"Delhi, NCR",
3,Meera,Chennai,"two
lines"
---
1 | Asha  | Pune       | "likes \"tea\""
2 | Ravi  | Delhi, NCR | ""
3 | Meera | Chennai    | "two\nlines"

Every value comes back as a string ('1', not 1) — cast numbers yourself. array_combine($header, $row) turns each row into an associative array keyed by column name.

Your turn

Compute the number of rows whose city contains a comma.

Error you will hit

Deprecated: fputcsv(): the $escape parameter must be provided

php
<?php
$out = fopen('php://memory', 'w+');
fputcsv($out, ['id', 'name', 'city']);
fputcsv($out, [1, 'Asha', 'Pune']);
rewind($out);
echo stream_get_contents($out);
Deprecated: fputcsv(): the $escape parameter must be provided as its default value will change in main.php on line 3

Deprecated: fputcsv(): the $escape parameter must be provided as its default value will change in main.php on line 4
id,name,city
1,Asha,Pune
Why PHP said that

The CSV functions have always used a non-standard backslash escape character by default, which can corrupt data containing \". PHP 8.4 started warning when you rely on that default, because it is going to change. The output is still produced — deprecations do not stop the script — but every row adds another notice to your logs.

The fix

Pass escape: '' to fputcsv, fgetcsv, str_getcsv and SplFileObject::setCsvControl.

php
<?php
$out = fopen('php://memory', 'w+');       // an in-memory stream: no file needed
fputcsv($out, ['id', 'name', 'city'], escape: '');
fputcsv($out, [1, 'Asha', 'Pune'], escape: '');
rewind($out);
echo stream_get_contents($out);
04

Directories

mkdir($path, 0775, true) creates a directory and, with the third argument, every missing parent. scandir() lists a directory (including . and .., which you filter out), glob() matches a pattern like *.csv, and RecursiveDirectoryIterator walks a whole tree. rmdir() only removes an empty directory, so deleting a tree means deleting the files first, deepest first.

phpmain.php
<?php
$root = sys_get_temp_dir() . '/hb-dirs-' . getmypid();
mkdir("$root/reports/2026", 0775, true);          // parents created too
file_put_contents("$root/readme.txt", 'hi');
file_put_contents("$root/reports/jan.csv", 'a,b');
file_put_contents("$root/reports/feb.csv", 'c,d');
file_put_contents("$root/reports/2026/mar.csv", 'e,f');

$entries = array_diff(scandir($root), ['.', '..']);
echo "top level: ", implode(', ', $entries), "\n";

$csvs = array_map('basename', glob("$root/reports/*.csv"));
echo "glob: ", implode(', ', $csvs), "\n";

$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::CHILD_FIRST,           // files before their folder
);
$all = [];
foreach ($it as $item) {
    $all[] = substr($item->getPathname(), strlen($root) + 1) . ($item->isDir() ? '/' : '');
}
sort($all);
echo implode("\n", $all), "\n";

foreach ($it as $item) {                               // delete the tree
    $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($root);
var_dump(is_dir($root));
Outputcompiled & run with real PHP
top level: readme.txt, reports
glob: feb.csv, jan.csv
readme.txt
reports/
reports/2026/
reports/2026/mar.csv
reports/feb.csv
reports/jan.csv
bool(false)

scandir() and glob() return sorted names; the recursive iterator does not promise any order, so the example sorts before printing. CHILD_FIRST is what makes the delete loop safe.

05

JSON: encode and decode

json_encode($value, $flags) turns arrays, objects and scalars into a JSON string. A list (keys 0..n-1) becomes a JSON array [...]; any other array becomes an object {...}. The flags you will use most: JSON_PRETTY_PRINT (indented, 4 spaces), JSON_UNESCAPED_SLASHES (no \/ in URLs), JSON_UNESCAPED_UNICODE (keep é as is) and JSON_THROW_ON_ERROR. Combine them with |.

phpmain.php
<?php
$user = [
    'id' => 7,
    'name' => 'Zoë',
    'site' => 'https://example.com/zoe',
    'tags' => ['admin', 'beta'],
    'score' => 10.0,
    'address' => null,
];

echo json_encode($user), "\n";

echo json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
    | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR), "\n";

echo json_encode([]), " ", json_encode([], JSON_FORCE_OBJECT), " ", json_encode(new stdClass()), "\n";
echo json_encode([1 => 'a', 2 => 'b']), " ", json_encode(array_values([1 => 'a', 2 => 'b'])), "\n";
Outputcompiled & run with real PHP
{"id":7,"name":"Zo\u00eb","site":"https:\/\/example.com\/zoe","tags":["admin","beta"],"score":10,"address":null}
{
    "id": 7,
    "name": "Zoë",
    "site": "https://example.com/zoe",
    "tags": [
        "admin",
        "beta"
    ],
    "score": 10.0,
    "address": null
}
[] {} {}
{"1":"a","2":"b"} ["a","b"]

Without JSON_PRESERVE_ZERO_FRACTION the float 10.0 is written as 10. An empty PHP array encodes as []. If the API expects an object, use JSON_FORCE_OBJECT or new stdClass(). After array_filter or unset, re-index with array_values() or your list turns into an object.

json_decode($json) gives you stdClass objects ($data->name). Pass true as the second argument for associative arrays ($data['name']), which is what most code uses because the array functions work on them. Always add JSON_THROW_ON_ERROR: without it, invalid JSON silently becomes null.

phpmain.php
<?php
$json = '{"order": 1042, "items": [{"sku": "PEN", "qty": 2}, {"sku": "INK", "qty": 1}], "paid": true}';

$obj = json_decode($json, flags: JSON_THROW_ON_ERROR);
echo get_class($obj), ": order ", $obj->order, ", first sku ", $obj->items[0]->sku, "\n";

$arr = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
echo gettype($arr), ": total qty ", array_sum(array_column($arr['items'], 'qty')), "\n";
var_dump($arr['paid']);

// Your own objects: JsonSerializable decides what gets encoded
final class Money implements JsonSerializable
{
    public function __construct(private int $paise, private string $currency) {}

    public function jsonSerialize(): array
    {
        return ['amount' => number_format($this->paise / 100, 2, '.', ''), 'currency' => $this->currency];
    }
}
echo json_encode(['total' => new Money(12550, 'INR')]), "\n";

try {
    json_decode('{"order": 1042,}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo "invalid: ", $e->getMessage(), "\n";
}
Outputcompiled & run with real PHP
stdClass: order 1042, first sku PEN
array: total qty 3
bool(true)
{"total":{"amount":"125.50","currency":"INR"}}
invalid: Syntax error

Without JsonSerializable, json_encode outputs only an object's public properties. JSON has no trailing commas, which is why the last decode fails.

Your turn

Decode '[{"n":"a","v":1},{"n":"b","v":2}]' as arrays and build ['a' => 1, 'b' => 2] with array_column($rows, 'v', 'n').

06

Dates with DateTimeImmutable

Use DateTimeImmutable for every date and time. It holds a moment plus a time zone, and every method that changes it (modify, add, setTimezone…) returns a new object, leaving the original untouched. The older DateTime class has the same methods but changes itself in place, which causes the classic bug of a "start date" that silently moves when you calculate an end date from it. Pass the time zone explicitly instead of relying on the server's date.timezone setting.

phpmain.php
<?php
$utc = new DateTimeZone('UTC');

$start = new DateTimeImmutable('2026-03-14 09:30:00', $utc);
$end = $start->modify('+10 days');

echo $start->format('Y-m-d H:i'), "\n";           // unchanged
echo $end->format('Y-m-d H:i'), "\n";
echo $start->format('l, j F Y'), "\n";
echo $start->format('D d/m/y g:i A'), "\n";
echo $start->format(DATE_ATOM), "\n";
echo $start->format('U'), "\n";                    // Unix timestamp

echo $start->modify('first day of next month')->format('Y-m-d'), "\n";
echo $start->modify('last day of this month')->format('Y-m-d'), "\n";
echo $start->modify('next monday')->format('Y-m-d l'), "\n";
echo $start->setTime(0, 0)->format('Y-m-d H:i:s'), "\n";

// The mutable trap
$m = new DateTime('2026-03-14', $utc);
$due = $m->modify('+30 days');                      // also changes $m!
echo $m->format('Y-m-d'), " ", $due->format('Y-m-d'), "\n";
Outputcompiled & run with real PHP
2026-03-14 09:30
2026-03-24 09:30
Saturday, 14 March 2026
Sat 14/03/26 9:30 AM
2026-03-14T09:30:00+00:00
1773480600
2026-04-01
2026-03-31
2026-03-16 Monday
2026-03-14 00:00:00
2026-04-13 2026-04-13

The last line is the DateTime bug: modify() changed $m itself and returned the same object, so the "start" moved.

Format charMeaningExample
Y / yyear, 4 / 2 digits2026 / 26
m / nmonth with / without leading zero03 / 3
d / jday with / without leading zero04 / 4
H / G, h / g24-hour, 12-hour (with / without zero)09, 9
i / sminutes / seconds30 / 00
AAM / PMAM
D / lweekday short / fullSat / Saturday
M / Fmonth name short / fullMar / March
T / P / ezone abbreviation / offset / identifierIST / +05:30 / Asia/Kolkata
UUnix timestamp1773480600

To parse a date in a known format, use DateTimeImmutable::createFromFormat(). Prefix the format with ! to reset the fields you do not give (otherwise they are filled from the current time). It returns false when the string does not match — check for it.

phpmain.php
<?php
$tz = new DateTimeZone('Asia/Kolkata');

$d = DateTimeImmutable::createFromFormat('!d/m/Y', '31/12/2026', $tz);
echo $d->format('Y-m-d H:i:s T'), "\n";

$bad = DateTimeImmutable::createFromFormat('!d/m/Y', '2026-12-31', $tz);
var_dump($bad);

$overflow = DateTimeImmutable::createFromFormat('!d/m/Y', '31/02/2026', $tz);
echo $overflow->format('Y-m-d'), "\n";                   // rolled over to March!
$errors = DateTimeImmutable::getLastErrors();
echo $errors['warning_count'], " warning: ", implode(', ', $errors['warnings']), "\n";
Outputcompiled & run with real PHP
2026-12-31 00:00:00 IST
bool(false)
2026-03-03
1 warning: The parsed date was invalid

An impossible date like 31 February does not fail — it overflows into March and records a warning. Validate user-entered dates by checking getLastErrors(), or by formatting the result back and comparing it to the input.

Error you will hit

DateMalformedStringException: Failed to parse time string

php
<?php
$due = new DateTimeImmutable('31/12/2026', new DateTimeZone('UTC'));
echo $due->format('Y-m-d');
Fatal error: Uncaught DateMalformedStringException: Failed to parse time string (31/12/2026) at position 0 (3): Unexpected character in main.php:2
Stack trace:
#0 main.php(2): DateTimeImmutable->__construct('31/12/2026', Object(DateTimeZone))
#1 {main}
  thrown in main.php on line 2
Why PHP said that

The constructor understands formats like 2026-12-31, 12/31/2026 (American month/day order!) and phrases like +1 week. 31/12/2026 would be month 31, so the parser gives up. Since PHP 8.3 this throws the specific DateMalformedStringException (a subclass of Exception) instead of a plain Exception.

The fix

For any day/month/year input, say the format explicitly with createFromFormat(). Keep the constructor for ISO dates (Y-m-d) and relative phrases.

php
<?php
$due = DateTimeImmutable::createFromFormat('!d/m/Y', '31/12/2026', new DateTimeZone('UTC'));
echo $due->format('Y-m-d');   // 2026-12-31
Error you will hit

Error: Call to a member function format() on false

php
<?php
$tz = new DateTimeZone('UTC');
$due = DateTimeImmutable::createFromFormat('d/m/Y', '2026-12-31', $tz);
echo $due->format('Y-m-d');
Fatal error: Uncaught Error: Call to a member function format() on false in main.php:4
Stack trace:
#0 {main}
  thrown in main.php on line 4
Why PHP said that

The input does not match d/m/Y, so createFromFormat() returned false — it does not throw. The crash happens one line later, when ->format() is called on false.

The fix

Check the result of createFromFormat() and throw (or report a validation error) yourself.

php
<?php
function parseDate(string $s): DateTimeImmutable
{
    $d = DateTimeImmutable::createFromFormat('!d/m/Y', $s, new DateTimeZone('UTC'));
    if ($d === false || $d->format('d/m/Y') !== $s) {
        throw new InvalidArgumentException("Expected dd/mm/yyyy, got '$s'");
    }
    return $d;
}
echo parseDate('31/12/2026')->format('Y-m-d');
07

DateInterval, diff() and date ranges

A DateInterval is a length of time written in ISO 8601: P starts the period, then years/months/days (P1Y2M10D), and T starts the time part (PT2H30M). Add or subtract it with add() / sub(). $a->diff($b) returns the interval between two dates: its y, m, d, h fields are the calendar breakdown, and days is the total number of whole days. DatePeriod iterates over a range in fixed steps.

phpmain.php
<?php
$utc = new DateTimeZone('UTC');
$joined = new DateTimeImmutable('2024-02-29', $utc);
$today = new DateTimeImmutable('2026-09-15', $utc);   // fixed "today" for the example

$age = $joined->diff($today);
echo "member for {$age->y} years, {$age->m} months, {$age->d} days\n";
echo "that is {$age->days} days in total\n";
echo $age->format('%y y %m m %d d (%a days)'), "\n";
var_dump($age->invert);                                // 0: $today is later

$trial = $joined->add(new DateInterval('P14D'));
$meeting = $joined->setTime(9, 0)->add(new DateInterval('PT2H30M'));
echo $trial->format('Y-m-d'), " ", $meeting->format('H:i'), "\n";

// Every Monday in a range
$period = new DatePeriod(
    new DateTimeImmutable('2026-09-01', $utc),
    new DateInterval('P1W'),
    new DateTimeImmutable('2026-10-01', $utc),
);
foreach ($period as $week) {
    echo $week->modify('monday this week')->format('D j M'), "  ";
}
echo "\n";
Outputcompiled & run with real PHP
member for 2 years, 6 months, 17 days
that is 929 days in total
2 y 6 m 17 d (929 days)
int(0)
2024-03-14 11:30
Mon 31 Aug  Mon 7 Sep  Mon 14 Sep  Mon 21 Sep  Mon 28 Sep

Use %a (or the days property) for "how many days between", never %d — that is only the leftover days after whole months.

VisualizeThe month-end trap: January 31 plus one monthStep 1 / 6
<?php
$utc = new DateTimeZone('UTC');
$jan31 = new DateTimeImmutable('2026-01-31', $utc);
$plus = $jan31->modify('+1 month');
echo $plus->format('Y-m-d'), "\n";
$safe = $jan31->modify('last day of next month');
echo $safe->format('Y-m-d'), "\n";
Line 3

Start on the last day of January.

Variables now
$jan312026-01-31
All 6 steps as a table
StepLineWhat happenedVariables now
13Start on the last day of January.$jan31 = 2026-01-31
24+1 month only bumps the month number: it asks for February 31, which does not exist.
34PHP overflows the extra days: February 2026 has 28, so day 31 becomes March 3.$plus = 2026-03-03
45The "one month later" billing date landed in the wrong month.
56last day of next month moves the month first, then picks its last day.$safe = 2026-02-28
67This is the answer a billing system wants.
08

Time zones and timestamps

The rule that avoids most date bugs: store and compute in UTC, convert to the user's time zone only for display. A Unix timestamp (seconds since 1970-01-01 00:00 UTC) is the same number everywhere; getTimestamp() returns it and new DateTimeImmutable('@1773480600') reads one back (always as UTC). setTimezone() changes how a moment is displayed — it is still the same instant. Use named zones such as Europe/London or America/New_York, not fixed offsets, so daylight saving time is handled for you.

phpmain.php
<?php
$utc = new DateTimeImmutable('2026-03-14 09:30:00', new DateTimeZone('UTC'));

foreach (['Asia/Kolkata', 'Europe/London', 'America/New_York', 'Australia/Sydney'] as $zone) {
    $local = $utc->setTimezone(new DateTimeZone($zone));
    printf("%-18s %s\n", $zone, $local->format('Y-m-d H:i T (P)'));
}

$ts = $utc->getTimestamp();
echo "timestamp: $ts\n";
$back = new DateTimeImmutable("@$ts");
echo "from timestamp: ", $back->format('Y-m-d H:i e'), "\n";
var_dump($back == $utc);                        // same instant, different zone objects

// Daylight saving: London clocks go forward on 29 March 2026
$london = new DateTimeZone('Europe/London');
$before = new DateTimeImmutable('2026-03-29 00:30', $london);
$after = $before->add(new DateInterval('PT1H'));
echo $before->format('H:i T'), " + 1 hour = ", $after->format('H:i T'), "\n";

// date() uses the default zone: set it once at startup
date_default_timezone_set('UTC');
echo date('Y-m-d H:i', $ts), "\n";
Outputcompiled & run with real PHP
Asia/Kolkata       2026-03-14 15:00 IST (+05:30)
Europe/London      2026-03-14 09:30 GMT (+00:00)
America/New_York   2026-03-14 05:30 EDT (-04:00)
Australia/Sydney   2026-03-14 20:30 AEDT (+11:00)
timestamp: 1773480600
from timestamp: 2026-03-14 09:30 +00:00
bool(true)
00:30 GMT + 1 hour = 02:30 BST
2026-03-14 09:30

Comparing DateTimeImmutable objects with ==, < and > compares the instants, so objects in different zones compare correctly. The DST line shows the missing hour: 01:30 never existed in London that night.

Your turn

Convert 2026-11-01 12:00 America/New_York to Asia/Kolkata. Why is the offset different from the one above?

In real jobs
Databases store TIMESTAMP/DATETIME columns; keep them in UTC and set the database connection's time zone to UTC too. Keep the user's zone as a separate string column ('Asia/Kolkata') and convert at the edge. For code that needs "now", inject a clock (Psr\Clock\ClockInterface) instead of calling new DateTimeImmutable() deep inside — then tests can freeze time, which is exactly what the fixed dates in these examples do.
Stream
A handle opened with fopen() that you read or write piece by piece; works for files, php://memory, php://stdin and network sockets.
File mode
The second argument of fopen(): r, w (truncates!), a, x, c, with + for read and write.
SplFileObject
An object wrapper around a file stream that can be iterated line by line and read CSV.
JSON_PRETTY_PRINT
Encode flag that indents JSON with 4 spaces and newlines.
Associative decode
json_decode($json, true) — JSON objects become PHP arrays instead of stdClass.
JsonSerializable
Interface whose jsonSerialize() method decides what json_encode outputs for an object.
DateTimeImmutable
A moment in time with a time zone; every change returns a new object.
DateInterval
A duration in ISO 8601 form, e.g. P1M, PT30M; returned by diff().
Unix timestamp
Seconds since 1970-01-01 00:00:00 UTC; independent of time zone.
UTC
Coordinated Universal Time, the zone with no offset and no daylight saving — the one to store and compute in.
Quick check

What does this print? $a = new DateTimeImmutable('2026-01-10'); $b = $a->modify('+5 days'); echo $a->format('d');

Frequently asked questions

Should I use DateTime or DateTimeImmutable in PHP?
Use DateTimeImmutable. Its methods return a new object instead of changing the original, so a start date can never be moved by accident when you compute an end date from it. DateTime has the same API but mutates itself, which is a common source of bugs.
How do I read a large file in PHP without running out of memory?
Open it with fopen() and read one line at a time with fgets() in a while loop (or iterate a SplFileObject), instead of file_get_contents() or file(), which load the entire file into memory. Memory use then stays constant regardless of file size.
Why does json_encode turn my PHP array into an object?
json_encode produces a JSON array only when the PHP array is a list with keys 0, 1, 2 and so on in order. After unset() or array_filter() the keys have gaps, so it becomes an object. Call array_values() first to re-index the array.

Finish the PHP handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.