Strings, Formatting and Dates

Article · 11:00

PHP strings are byte strings. That matters the moment a learner name has an accent in it, so the multibyte functions are not optional extras. With formatting, and date objects that cannot change underneath the code holding them.

This lesson has 11 runnable examples. Press Try yourself! under any of them to open it in the editor.

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 Example 1 13 lines
<?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 Example 2 9 lines
<?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 Example 3 14 lines
<?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 Example 4 21 lines
<?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 Example 5 12 lines
<?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 Example 6 12 lines
<?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 Example 7 12 lines
<?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 Example 8 19 lines
<?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 Example 9 12 lines
<?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 Example 10 85 lines
<?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 Example 11 5 lines
<?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.