Strings, Encodings and Dates
Two areas where PHP's history shows most clearly: the string functions predate Unicode, and the date API was replaced rather than fixed. Both are fine once you know which half to use.
Bytes against characters
PHP strings are byte strings. The functions without an mb_ prefix count bytes; the ones with it count characters.
<?php
declare(strict_types=1);
$text = 'cafe' . mb_chr(0x301); // 'cafe' plus a combining acute accent
$simple = 'caf' . mb_chr(0xe9); // 'caf' plus a precomposed e-acute
echo strlen($simple), PHP_EOL; // 5 - BYTES
echo mb_strlen($simple), PHP_EOL; // 4 - characters
echo mb_strlen($text), PHP_EOL; // 5 - e and accent are two
echo grapheme_strlen($text), PHP_EOL; // 4 - what a reader sees
echo substr($simple, 0, 4), PHP_EOL; // 'caf' plus half a character
echo mb_substr($simple, 0, 4), PHP_EOL; // 'café'
Open this example in the editor and change it. Nothing you type is saved.
That substr is the bug: cutting a multi-byte character in half produces invalid UTF-8, which then breaks json_encode, the database write, or the page that displays it.
The rule: use mb_* for anything that came from a user. mb_strlen, mb_substr, mb_strtolower, mb_str_split, mb_strpos. The non-mb_ versions are correct for ASCII-only data such as a hex hash or a generated identifier, and only there.
<?php
declare(strict_types=1);
$name = "ÅSA";
echo strtolower($name), PHP_EOL; // 'Åsa' - the Å is untouched
echo mb_strtolower($name), PHP_EOL; // 'åsa'
var_dump(mb_check_encoding("abc", 'UTF-8'));
var_dump(mb_detect_encoding("abc", ['UTF-8', 'ISO-8859-1'], true));
Open this example in the editor and change it. Nothing you type is saved.
Normalisation matters when comparing text from different sources:
<?php
declare(strict_types=1);
$composed = 'caf' . mb_chr(0xe9); // one code point
$decomposed = 'cafe' . mb_chr(0x301); // 'e' plus combining accent
var_dump($composed === $decomposed); // false
var_dump(mb_strlen($composed), mb_strlen($decomposed)); // 4 5
if (class_exists('Normalizer')) {
var_dump(Normalizer::normalize($decomposed, Normalizer::FORM_C) === $composed);
} else {
echo "intl not installed", PHP_EOL;
}
Open this example in the editor and change it. Nothing you type is saved.
Two strings that render identically can compare unequal. Normalise to NFC at the boundary - on the way into the database - and the comparison problem disappears for everyone downstream.
The functions worth knowing
<?php
declare(strict_types=1);
$line = " Content-Type: text/html; charset=utf-8 ";
echo trim($line), PHP_EOL;
var_dump(str_contains($line, 'charset')); // PHP 8
var_dump(str_starts_with(trim($line), 'Content'));
var_dump(str_ends_with(trim($line), 'utf-8'));
[$key, $value] = explode(':', trim($line), 2); // limit 2: split once
echo $key, ' => ', trim($value), PHP_EOL;
print_r(explode(',', 'a,b,,c')); // ['a','b','','c']
print_r(array_filter(explode(',', 'a,b,,c'), 'strlen'));
echo implode(' | ', ['a', 'b', 'c']), PHP_EOL;
echo str_pad('7', 3, '0', STR_PAD_LEFT), PHP_EOL; // 007
echo str_repeat('-', 10), PHP_EOL;
echo ucfirst('hello'), ' ', ucwords('hello world'), PHP_EOL;
echo wordwrap('a rather long sentence here', 12, PHP_EOL, true), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
str_contains, str_starts_with and str_ends_with arrived in PHP 8 and replace the strpos(...) !== false dance, which was easy to get wrong because strpos returns 0 for a match at the start.
Building strings: concatenate for a few, implode for a list, sprintf for a format.
<?php
declare(strict_types=1);
$name = 'Aisha';
$score = 0.8637;
$count = 7;
printf("%s scored %.1f%% over %d attempts%s", $name, $score * 100, $count, PHP_EOL);
echo sprintf("%-10s|%10s|%'*10s", 'left', 'right', 'padded'), PHP_EOL;
echo sprintf("%05.2f %x %b %e", 3.14159, 255, 5, 12345.678), PHP_EOL;
echo number_format(1234567.891, 2), PHP_EOL; // 1,234,567.89
echo number_format(1234567.891, 2, ',', '.'), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
vsprintf takes an array, which is useful when the arguments are built dynamically. And printf returns the length written, not the string - sprintf is the one that returns it.
Dates
Use the object API. date() and strtotime() work on a global default timezone, which is a hidden input.
<?php
declare(strict_types=1);
$utc = new DateTimeImmutable('2026-10-07 14:30:00', new DateTimeZone('UTC'));
echo $utc->format('Y-m-d H:i:s T'), PHP_EOL; // 2026-10-07 14:30:00 UTC
echo $utc->format(DateTimeInterface::ATOM), PHP_EOL; // 2026-10-07T14:30:00+00:00
echo $utc->format('D, j M Y'), PHP_EOL; // Wed, 7 Oct 2026
$london = $utc->setTimezone(new DateTimeZone('Europe/London'));
echo $london->format('Y-m-d H:i:s T'), PHP_EOL; // 15:30:00 BST
var_dump($utc->getTimestamp() === $london->getTimestamp()); // true
Open this example in the editor and change it. Nothing you type is saved.
DateTimeImmutable, never DateTime. The mutable version's modify() and add() change the object in place and also return it, so a line that looks like it produces a new value quietly alters the old one:
<?php
declare(strict_types=1);
$mutable = new DateTime('2026-10-07');
$tomorrow = $mutable->modify('+1 day');
echo $mutable->format('Y-m-d'), PHP_EOL; // 2026-10-08 - it moved!
var_dump($mutable === $tomorrow); // true - same object
$immutable = new DateTimeImmutable('2026-10-07');
$next = $immutable->modify('+1 day');
echo $immutable->format('Y-m-d'), PHP_EOL; // 2026-10-07 - intact
echo $next->format('Y-m-d'), PHP_EOL; // 2026-10-08
Open this example in the editor and change it. Nothing you type is saved.
Arithmetic and differences:
<?php
declare(strict_types=1);
$start = new DateTimeImmutable('2026-01-15', new DateTimeZone('UTC'));
echo $start->add(new DateInterval('P1M'))->format('Y-m-d'), PHP_EOL; // +1 month
echo $start->modify('+1 month')->format('Y-m-d'), PHP_EOL; // the same
echo $start->modify('last day of this month')->format('Y-m-d'), PHP_EOL;
echo $start->modify('next monday')->format('Y-m-d'), PHP_EOL;
$end = new DateTimeImmutable('2026-03-01', new DateTimeZone('UTC'));
$diff = $start->diff($end);
printf("%d month(s), %d day(s), %d days total%s",
$diff->m, $diff->d, $diff->days, PHP_EOL);
// Month arithmetic overflows, which surprises people every time.
$jan31 = new DateTimeImmutable('2026-01-31', new DateTimeZone('UTC'));
echo $jan31->modify('+1 month')->format('Y-m-d'), PHP_EOL; // 2026-03-03
echo $jan31->modify('last day of next month')->format('Y-m-d'), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
"One month after 31 January" is 31 February, which PHP normalises to 3 March. That is consistent rather than wrong, and last day of next month is what you usually meant.
Parsing, strictly:
<?php
declare(strict_types=1);
$parsed = DateTimeImmutable::createFromFormat(
'!Y-m-d', '2026-10-07', new DateTimeZone('UTC'));
var_dump($parsed instanceof DateTimeImmutable);
echo $parsed->format('Y-m-d H:i:s'), PHP_EOL; // 00:00:00 - the ! reset it
$bad = DateTimeImmutable::createFromFormat('!Y-m-d', 'not a date');
var_dump($bad); // false
print_r(DateTimeImmutable::getLastErrors()['errors'] ?? []);
Open this example in the editor and change it. Nothing you type is saved.
The leading ! resets every unspecified field to zero. Without it, the time fields default to now, so parsing a date gives you a different value each second.
A worked example
<?php
declare(strict_types=1);
final class Slot
{
public function __construct(
public readonly DateTimeImmutable $start,
public readonly DateTimeImmutable $end,
public readonly string $title,
) {}
public function minutes(): int
{
return (int) (($this->end->getTimestamp() - $this->start->getTimestamp()) / 60);
}
public function inZone(string $zone): string
{
$tz = new DateTimeZone($zone);
return $this->start->setTimezone($tz)->format('D H:i T')
. ' to ' . $this->end->setTimezone($tz)->format('H:i');
}
}
function parseSlot(string $line): ?Slot
{
$parts = array_map('trim', explode('|', $line));
if (count($parts) !== 3) {
return null;
}
[$startRaw, $minutesRaw, $title] = $parts;
$start = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i', $startRaw, new DateTimeZone('UTC'));
$minutes = filter_var($minutesRaw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($start === false || $minutes === false) {
return null;
}
return new Slot($start, $start->add(new DateInterval('PT' . $minutes . 'M')), $title);
}
$schedule = <<<'LINES'
2026-10-07 09:00 | 90 | Selectors and the cascade
2026-10-07 11:00 | 45 | The box model
2026-10-07 14:30 | 120 | Grid in two dimensions
not a slot
2026-10-07 16:00 | x | Bad duration
LINES;
$slots = [];
foreach (explode(PHP_EOL, $schedule) as $line) {
$slot = parseSlot($line);
if ($slot !== null) {
$slots[] = $slot;
}
}
printf("%d slot(s) parsed%s%s", count($slots), PHP_EOL, PHP_EOL);
foreach ($slots as $slot) {
printf("%-28s %-22s %3d min%s",
mb_strimwidth($slot->title, 0, 28, '...'),
$slot->inZone('Europe/London'),
$slot->minutes(),
PHP_EOL);
}
$total = array_sum(array_map(fn (Slot $s): int => $s->minutes(), $slots));
printf("%sTotal %d minutes (%d h %02d m)%s",
PHP_EOL, $total, intdiv($total, 60), $total % 60, PHP_EOL);
// The gap between the last two slots, computed rather than eyeballed.
$gap = $slots[1]->end->diff($slots[2]->start);
printf("Gap before the last slot: %d h %02d m%s", $gap->h, $gap->i, PHP_EOL);
// And the same schedule in another timezone, from the same objects.
echo PHP_EOL, 'In New York:', PHP_EOL;
foreach ($slots as $slot) {
printf(" %-28s %s%s",
mb_strimwidth($slot->title, 0, 28, '...'),
$slot->inZone('America/New_York'),
PHP_EOL);
}
Open this example in the editor and change it. Nothing you type is saved.
Three decisions make that work. Every instant is stored in UTC and converted only for display, so the timezone is a presentation concern rather than a hidden global. createFromFormat with a leading ! means parsing the same line twice gives the same answer. And mb_strimwidth truncates by display width rather than bytes, so a title with an accent in it is not cut in half.
When it goes wrong
| Symptom | Cause |
|---|---|
| Truncated text shows a replacement character | substr cut a multi-byte character; use mb_substr |
strlen disagrees with what you see |
It counts bytes; use mb_strlen or grapheme_strlen |
strtolower left accented letters alone |
Use mb_strtolower |
| Two identical-looking strings are unequal | Different normalisation; normalise to NFC |
| A date moved when you only read it | DateTime is mutable; use DateTimeImmutable |
| "+1 month" from the 31st skipped a month | Overflow; use last day of next month |
| A parsed date has today's time in it | Missing the leading ! in the format |
| Times are right locally and wrong in production | A different default timezone; always pass one |
A check you can run
<?php
$a = new DateTime('2026-01-31');
$b = $a->modify('+1 month');
echo $a->format('Y-m-d'), ' ', $b->format('Y-m-d'), PHP_EOL;
var_dump($a === $b);
Open this example in the editor and change it. Nothing you type is saved.
Both dates print the same, and $a === $b is true - one object, two names, and the original is gone. Change DateTime to DateTimeImmutable and run it again: two different dates, and false.
Two words changed, and a whole class of "why did that date move" bug disappears. There is no good reason to use the mutable class in new code.