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.
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.