Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 15 30 MIN

Types and data

Arrays, Strings and the Standard Library

The one array type that is both list and map, the array and string functions worth knowing, and why byte-based string functions corrupt UTF-8.

A JSON endpoint returns a list of products. The front end iterates it. Then someone filters out the products with no stock, the response comes back with keys 0, 2, 5, and the client — which checks for an array — sees an object and renders nothing. The server did exactly what array_filter promises. What it also did was turn a list into a map without anyone deciding to.

That failure comes from a property PHP arrays have had since the beginning and most languages do not share: there is one array type, and it serves as both indexed list and keyed map. Every array function inherits that ambiguity, and every string function inherits a second one, which is that the defaults count bytes rather than characters. The standard library is not hostile; it is old, and the two design decisions behind it are the source of most of the surprises in this lesson.

The output below is real, from PHP 8.5.6 with mbstring loaded, and the code targets PHP 8.3 and newer. Paths in messages are shortened to /path/to/; everything else is verbatim.

One array type covers both lists and maps

An array is an ordered map from keys to values. A list is not a separate type; it is a map whose keys happen to be 0, 1, 2... in order. Nothing enforces that, which is why you can build something that is neither.

<?php
declare(strict_types=1);

$list = ['a', 'b', 'c'];
$map  = ['sku' => 'SKU-9', 'qty' => 4];
$neither = [1 => 'a', 2 => 'b'];

var_dump(array_is_list($list));
var_dump(array_is_list($map));
var_dump(array_is_list($neither));

$a = [0 => 'first', 1 => 'second'];
$b = [1 => 'second', 0 => 'first'];

var_dump($a === $b);
var_dump($a == $b);
bool(true)
bool(false)
bool(false)
bool(false)
bool(true)

array_is_list($array) answers the question that matters at the boundary between PHP and everything else: are the keys 0..n-1 in order? It returns false for the value in $neither even though that array is perfectly well-formed, which is the point. The function tests the contract, not the health of the array.

The comparison is the detail that decides whether two arrays that print identically are the same value. $a and $b hold the same pairs in opposite order, so identity is false. Loose equality between two arrays compares the pairs without regard to order, so it is true, and that has been the behaviour on every version this course targets. Order is part of how the array is stored, not part of the values it holds, which is exactly why array_is_list() exists as a separate question.

Keys are cast, and the cast silently merges entries

Array keys are limited to integers and strings, and anything else you use as a key is converted before it is stored. The conversions are worth seeing once, because they explain bugs where a lookup fails against data you can see is present.

<?php
declare(strict_types=1);

$keys = [];
$keys[1] = 'int one';
$keys['1'] = 'string one';
$keys[true] = 'bool one';

var_dump($keys);
echo count($keys), PHP_EOL;
array(1) {
  [1]=>
  string(8) "bool one"
}

1

Three assignments, one entry. The integer 1, the numeric string '1' and the boolean true are the same key after casting, so each write replaced the previous one and the survivor holds the last value written. A float key truncates toward zero and reports a deprecation, and null as a key is deprecated in PHP 8.4 in favour of the empty string, a change worth knowing if you have older code that used null as a sentinel key.

The practical rule is to be explicit about the type of your keys. The string '1' from a database column reaches the same entry as the integer 1, but '01' is not a numeric string in the canonical form PHP accepts, so it stays a string key and points at a different entry. Two keys that look alike to a human, and PHP treats them as one or as two depending on a leading zero. Normalise keys on the way in rather than hoping.

array_map, array_filter and array_reduce each have one trap

These three functions do most of the day-to-day work, and each has a single mistake that people make once because the signature is not arranged the way they expect.

<?php
declare(strict_types=1);

$qty = ['SKU-9' => 4, 'SKU-10' => 0, 'SKU-11' => '0', 'SKU-12' => 2];

$inStock = array_filter($qty);
printf("no callback keeps %d of %d rows\n", count($inStock), count($qty));
var_dump($inStock);

$byIndex = array_filter([1 => 'a', 2 => 'b', 3 => 'c'], static fn (int $k): bool => $k !== 2, ARRAY_FILTER_USE_KEY);
var_dump($byIndex);
no callback keeps 2 of 4 rows
array(2) {
  ["SKU-9"]=>
  int(4)
  ["SKU-12"]=>
  int(2)
}
array(2) {
  [1]=>
  string(1) "a"
  [3]=>
  string(1) "c"
}

array_filter($array) with no callback removes every value that is falsy, which includes the integer 0 and the string '0'. Two rows disappeared rather than one: SKU-11 held the string '0', a valid quantity that arrived from a form post, and the filter treated it as absent. Whenever a zero is a legitimate value, pass an explicit callback and say what you mean: static fn (int|string $n): bool => (int) $n > 0.

The two flags move the callback's subject. ARRAY_FILTER_USE_KEY hands it the key alone and ARRAY_FILTER_USE_BOTH hands it the value first and the key second, which is the opposite of what most people guess. The result keeps the original keys, so filtering a list usually leaves you wanting array_values() around it to restore the 0..n-1 sequence.

array_map() takes the callback first and the arrays after it, which is the reverse of the order the name suggests and the reason this mistake is common:

<?php
declare(strict_types=1);

try {
    array_map([1, 2, 3], fn (int $n): int => $n * 2);
} catch (TypeError $e) {
    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}

$cents = ['SKU-9' => 1999, 'SKU-10' => 250];
var_dump(array_map(static fn (int $c): string => sprintf('%.2f', $c / 100), $cents));
var_dump(array_map(null, [1, 2], [3, 4]));
array(2) { ["SKU-9"]=> string(4) "19.99"  ["SKU-10"]=> string(2) "2.50" }
TypeError: array_map(): Argument #1 ($callback) must be a valid callback or null, array callback must have exactly two members
array(2) { [0]=> array(2) { [0]=> int(1)  [1]=> int(3) }  [1]=> array(2) { [0]=> int(2)  [1]=> int(4) } }

The error message tells you what PHP thought [1, 2, 3] was: the two-element array form of a callable, method name and object. So the mistake produces a message about callbacks rather than about argument order, which sends people looking in the wrong direction. Notice also that array_map preserved the string keys while array_reduce will not. Passing null as the callback makes the function zip its arrays into rows, which is occasionally exactly what you want and is otherwise a good way to confuse the next reader.

array_reduce() takes the accumulator first and the current element second, and it walks values only, so the keys are gone by the time your callback runs:

<?php
declare(strict_types=1);

$cents = [1999, 250, 10];
var_dump(array_reduce($cents, static fn (int $carry, int $item): int => $carry + $item, 0));

$lines = ['sku' => 'SKU-9', 'qty' => 4];
echo array_reduce($lines, static fn (string $carry, mixed $item): string => $carry . '[' . $item . ']', ''), PHP_EOL;
int(2259)
[SKU-9][4]

The third argument is the initial value, and omitting it makes null the first accumulator, which then meets your typed int $carry parameter and throws. Always pass it. If you need the keys during a reduction, walk the array yourself with foreach; it is shorter than working around the signature.

array_key_exists and isset answer different questions about null

These two are not interchangeable, and the difference is a single value. One asks whether a key is present. The other asks whether a key is present and holds something other than null.

<?php
declare(strict_types=1);

$row = ['coupon' => null, 'sku' => 'SKU-9', 'qty' => 0];

var_dump(isset($row['coupon']));
var_dump(array_key_exists('coupon', $row));
var_dump(isset($row['missing']));
var_dump(array_key_exists('missing', $row));
var_dump($row['coupon'] ?? 'default');
var_dump($row['qty'] ?? 'default');
bool(false)
bool(true)
bool(false)
bool(false)
string(7) "default"
string(1) "0"

The first two lines are the whole lesson. isset($row['coupon']) is false because the value is null, and array_key_exists('coupon', $row) is true because the key exists. If your code is deciding whether to write a column during a partial update, that distinction decides between leaving the column alone and setting it to null, and getting it backwards means one of those two intents is silently lost.

The ?? operator on the fifth line shows the related behaviour: it treats null and a missing key the same way and returns the default for both. That is usually what you want when reading, and it is the reason isset() inside a guard is often better written as ??. When the difference between null and absent matters, reach for array_key_exists() and say so in a comment, because the code will look unusual to a reader who has only seen the idiom.

sprintf, str_pad and explode carry most of the formatting work

Four functions cover a surprising share of the string work in an application. sprintf formats into a new string, str_pad fills to a width, explode splits and implode joins.

<?php
declare(strict_types=1);

echo sprintf('%-10s|%8.2f|%05d|%.3f', 'SKU-9', 19.99, 42, 2.0), PHP_EOL;
printf("order %d for %s\n", 1043, 'ana');

$sku = 'SKU';
printf("[%s]\n", str_pad($sku, 8, '0', STR_PAD_LEFT));
printf("[%s]\n", str_pad($sku, 8));
printf("[%s]\n", str_pad($sku, 8, '-', STR_PAD_BOTH));

$csv = 'SKU-9,4,19.99';
$parts = explode(',', $csv);
var_dump($parts);
echo implode(' | ', $parts), PHP_EOL;
var_dump(explode(',', 'a,,b'));

var_dump(str_contains('SKU-9', 'KU'));
var_dump(str_contains('SKU-9', 'sku'));
SKU-9     |   19.99|00042|2.000
order 1043 for ana
[00000SKU]
[SKU     ]
[--SKU---]
array(3) {
  [0]=>
  string(5) "SKU-9"
  [1]=>
  string(1) "4"
  [2]=>
  string(5) "19.99"
}
SKU-9 | 4 | 19.99
array(3) {
  [0]=>
  string(1) "a"
  [1]=>
  string(0) ""
  [2]=>
  string(1) "b"
}
bool(true)
bool(false)

Two details in that output matter more than the rest. explode never discards empty fields, so 'a,,b' produces three elements with an empty string in the middle; counting the result tells you how many fields were present, which is what you want when parsing a fixed format and not what you want when parsing user input. And str_contains() is case sensitive, so searching for 'sku' in 'SKU-9' returns false. For a case-insensitive search, normalise both sides with strtolower(), or use stripos() if you expect the needle to be absent most of the time and want to avoid building a folded copy of the haystack.

sprintf() is worth learning properly because the format string is also the documentation. %-10s left-aligns in a ten-character field, %8.2f right-aligns a float with two decimals, %05d pads an integer with zeros to five digits, and %.3f fixes three decimals. It is also stricter than its reputation suggests: too few arguments throws ArgumentCountError, and an unrepresentable width throws ValueError: Width must be between 0 and 2147483647. It will not, however, complain about a value that looks wrong for the placeholder. sprintf('%d', 'abc') returns 0 without a warning, so the format string checks the shape of your arguments and never their meaning.

Byte-based functions break on UTF-8 and mb_ functions do not

This is the failure that reaches production because it works for every test written in English. strlen(), substr(), str_pad() and str_split() count and cut bytes. In UTF-8 a character can be one to four bytes, so the two numbers diverge as soon as the string contains anything outside ASCII, and a cut at a byte boundary can land in the middle of a character.

<?php
declare(strict_types=1);
mb_internal_encoding('UTF-8');

$name = 'Café Zürich';

printf("strlen    = %d\n", strlen($name));
printf("mb_strlen = %d\n", mb_strlen($name));
printf("mb_strlen(cp1252) = %d\n", mb_strlen($name, 'Windows-1252'));

$text = 'Résumé — done';
printf("strlen = %d, mb_strlen = %d\n", strlen($text), mb_strlen($text));
printf("substr(0,7)   = %s  json: %s\n", substr($text, 0, 7), json_encode(substr($text, 0, 7)));
printf("mb_substr(0,7)= %s\n", mb_substr($text, 0, 7));
strlen    = 13
mb_strlen = 11
mb_strlen(cp1252) = 13
strlen = 17, mb_strlen = 13
substr(0,7)   = Résum�  json: 
mb_substr(0,7)= Résumé 

Three problems are visible. strlen('Café Zürich') is 13 and mb_strlen is 11, because é and ü are two bytes each. The third line is the trap inside the trap: mb_strlen($name, 'Windows-1252') also returns 13, because telling a multibyte function to count a single-byte encoding gives you byte counts with a different name.

The substr line is where data gets corrupted. Cutting seven bytes out of 'Résumé — done' splits the é in half, and the two bytes that remain are not a valid UTF-8 sequence. Note what json_encode() returns: nothing. It fails on invalid UTF-8 and prints an empty string, so a JSON endpoint that truncated a title this way returns a body the client cannot parse, and the failure looks like an encoding problem in the client rather than a byte count on the server. mb_substr() cuts seven characters and produces 'Résumé ' with the character intact.

The same rule covers padding and splitting, with a wrinkle that catches people mid-migration. Since PHP 8.3, mb_str_pad() exists and takes the same arguments as str_pad() plus an optional encoding, which is the correct choice for column alignment in a report:

<?php
declare(strict_types=1);
mb_internal_encoding('UTF-8');

$label = 'Zürich';
printf("[%s] str_pad    => %d chars %d bytes\n", str_pad($label, 10), mb_strlen(str_pad($label, 10)), strlen(str_pad($label, 10)));
printf("[%s] mb_str_pad => %d chars %d bytes\n", mb_str_pad($label, 10), mb_strlen(mb_str_pad($label, 10)), strlen(mb_str_pad($label, 10)));

printf("mb_str_split('Zür') = %s\n", implode('|', mb_str_split('Zür')));
printf("str_split('Zür')    = %s\n", implode('|', array_map(static fn (string $b): string => bin2hex($b), str_split('Zür'))));
[Zürich   ] str_pad    => 9 chars 10 bytes
[Zürich    ] mb_str_pad => 10 chars 11 bytes
mb_str_split('Zür') = Z|ü|r
str_split('Zür')    = 5a|c3|bc|72

Both lines produce ten bytes and only one produces ten characters. The byte-counting version cannot pad 'Zürich' to a character width, because six characters occupy seven bytes, so it stops two spaces short of the column. The split functions show the same thing from the other direction: str_split returns the bytes c3 and bc as separate elements for the single character ü, and reassembling them in a different order produces broken text.

Set the encoding once, at the top

mb_internal_encoding('UTF-8') makes the mb_* functions default to UTF-8 instead of the mbstring.internal_encoding INI value, which varies by installation and is UTF-8 by default in current PHP versions. It is one line in a bootstrap file and it removes a class of bug that only appears on somebody else's machine.

What to do with all this is a short list. Use the mb_* functions for anything a user can type, and set the internal encoding in your bootstrap. Keep the byte-based functions for binary data, hashes and protocol parsing, where a byte count is what the format specifies. When you need to validate, mb_check_encoding($value, 'UTF-8') tells you whether a string is well formed before you hand it to json_encode and find out the hard way.

Lab 30 minutes

Write catalogue.php with declare(strict_types=1) and mb_internal_encoding('UTF-8'), then an array of four products keyed by SKU, each with a name, a quantity and a price in cents. Include one name with an accented character, one product with a quantity of 0, and one with a quantity of '0' arriving as a string.

Produce three outputs from the same data. First, the products with stock using array_filter with an explicit callback, then again without one, and explain the difference in the count. Second, a padded report line per product built with sprintf and mb_str_pad, so that the accented name aligns in the same column as the ASCII names. Third, the array of in-stock products encoded as JSON, and a check with json_last_error() or JSON_THROW_ON_ERROR to prove the payload is valid.

Then answer two questions. What does array_filter do to the keys of the result, and what does that do to the JSON payload when you encode it without array_values? And if you replaced mb_str_pad with str_pad, which line of the report moves and why?

Show the solution and the reasoning

The design decision is where to normalise. Quantities arriving from a form are strings, so converting them once at the boundary means the rest of the code can compare them with > and not worry about whether a zero is the integer or the string.

<?php
declare(strict_types=1);
mb_internal_encoding('UTF-8');

$products = [
    'SKU-9' => ['name' => 'Café Zürich', 'qty' => 4, 'cents' => 1999],
    'SKU-10' => ['name' => 'Espresso', 'qty' => 0, 'cents' => 250],
    'SKU-11' => ['name' => 'Crème brûlée', 'qty' => '0', 'cents' => 550],
    'SKU-12' => ['name' => 'Ristretto', 'qty' => 2, 'cents' => 300],
];

$inStock = array_filter($products, static fn (array $p): bool => (int) $p['qty'] > 0);
printf("with callback: %d of %d\n", count($inStock), count($products));
printf("without callback: %d\n", count(array_filter($products)));

foreach ($products as $sku => $product) {
    printf(
        "%s %s %s\n",
        mb_str_pad($sku, 10),
        mb_str_pad($product['name'], 16),
        sprintf('%8.2f', $product['cents'] / 100),
    );
}

$payload = json_encode(array_values($inStock), JSON_THROW_ON_ERROR);
echo $payload, PHP_EOL;
with callback: 2 of 4
without callback: 4
SKU-9      Café Zürich         19.99
SKU-10     Espresso             2.50
SKU-11     Crème brûlée         5.50
SKU-12     Ristretto            3.00
[{"name":"Caf\u00e9 Z\u00fcrich","qty":4,"cents":1999},{"name":"Ristretto","qty":2,"cents":300}]

The two filters disagree, and the reason is more interesting than a rounding error. The explicit callback keeps the two rows whose quantity is a positive integer. The bare array_filter($products) keeps all four, because every element is a non-empty array and a non-empty array is truthy no matter what its contents say. Removing the callback did not make the filter more aggressive; it made it answer a question nobody asked. This is the shape of bug the section on falsy values was warning about, one level up: the callback is what tells the reader which field the decision is about.

The keys are the part that matters for JSON. array_filter preserved the original SKU keys, so encoding $inStock directly produces {"SKU-9":...,"SKU-12":...}, an object, while the front end expects an array. array_values() restores the 0..n-1 sequence and the payload becomes a JSON array. array_map behaves the same way, which is why the array_values() call belongs next to any filter or map whose result crosses a boundary.

str_pad would move the two accented rows. 'Café Zürich' is fourteen characters and sixteen bytes, so a byte-based pad to sixteen adds nothing, while the ASCII name 'Espresso' is eight characters and eight bytes and gets eight spaces. The accented rows would sit two columns to the left of the others in a terminal, and the misalignment would grow with every multi-byte character in the name.

JSON_THROW_ON_ERROR is the reason the encoding line is safe. Without it, invalid UTF-8 from a byte-truncated name makes json_encode return false and records JSON_ERROR_UTF8, whose message from json_last_error_msg() on this machine reads Malformed UTF-8 characters, possibly incorrectly encoded. The failure then surfaces later as an empty response body with a 200 status. With the flag, the same input throws JsonException with the same message on the line that did the encoding, which is where you can fix it.

One reasonable variation: keep the SKU-keyed structure all the way to the client and let the client treat the response as an object. That is a decision about the API contract, not about PHP, and it is defensible as long as the client knows which one to expect.

Progress

Mark this lesson done and it stays marked in this browser.