Lesson 10 / 14
String Handling
Formatting with sprintf and printf; the byte and character distinction between strlen and mb_strlen; malformed JSON coming back from json_decode as null; telling the cases apart with json_last_error and JSON_THROW_ON_ERROR; the behavior of implode and strlen on an array argument.
Published Updated
In this lesson
- Formats numbers and strings with sprintf and measures that the width specifier works in bytes
- Measures that malformed JSON returns null from json_decode and records it under the return channel
- Separates a valid null from malformed input with json_last_error and JSON_THROW_ON_ERROR
Contents
The Array Functions lesson showed that the discriminating power of the return channel is limited by the discriminating power of the caller: once 0 and false were not told apart, the first element found counted as “absent”. String handling brings out the same limit in a harsher form. The null returned when a string is decoded can come from a valid null value or from malformed input, and the value itself does not say which one it is.
A string in PHP is a sequence of bytes; how many bytes a character takes depends on the function that reads the string. The lesson covers two subjects: the functions that format and measure a string, and the functions that encode and decode a string as JSON. The channel names are used in the sense they had in the previous lessons: silent, return, warning, halts.
Formatting and Byte Measurement
sprintf places values into a format string; printf does the same formatting and prints it directly. Width, alignment and the number of decimal places are given with a specifier.
<?php // format.php — formatting with sprintf echo sprintf('%05d', 42), "\n"; echo sprintf('%.2f', 3.14159), "\n"; echo sprintf('%-8s|', 'php'), "\n"; echo sprintf('%8s|', 'php'), "\n"; echo sprintf('%s book, %d pages', 'Lesson', 120), "\n"; printf("%x %o %b\n", 255, 8, 5); echo sprintf('%1$s-%2$s-%1$s', 'a', 'b'), "\n";
00042
3.14
php |
php|
Lesson book, 120 pages
ff 10 101
a-b-a
The width specifier counts bytes, not characters. The difference shows in a string that contains a multibyte letter; the file below prints only lengths and does not build an aligned column.
<?php // bytes.php — strlen counts bytes, mb_strlen counts characters $name = 'señor'; echo 'strlen: ', strlen($name), "\n"; echo 'mb_strlen: ', mb_strlen($name), "\n"; $padded = sprintf('%-8s', $name); echo 'padded bytes: ', strlen($padded), "\n"; echo 'padded characters: ', mb_strlen($padded), "\n"; echo 'ascii strlen: ', strlen('senor'), "\n"; echo 'ascii mb_strlen: ', mb_strlen('senor'), "\n";
strlen: 6 mb_strlen: 5 padded bytes: 8 padded characters: 7 ascii strlen: 5 ascii mb_strlen: 5
strlen('señor') gave six and mb_strlen('señor') gave five: ñ is two bytes and a single character. The string padded to eight bytes with sprintf('%-8s') looks only seven characters wide; in an aligned column, this shift pulls the next column to the left. For the all-ASCII senor, the two functions give the same number, so the difference appears only when multibyte data arrives. The problem is reported through no channel: silent. Before data that contains multibyte letters is placed in an aligned column, the width must be calculated over characters, not bytes.
AS13. This lesson uses only mb_strlen from the mb_ family and assumes the input is in a single encoding (UTF-8). Input whose encoding is unspecified or mixed lies outside this lesson and is not measured.
Malformed JSON
JSON is an interchange format that turns arrays and associative arrays into text. json_encode converts a PHP value to text, and json_decode converts text to a PHP value. The listener classifies what each does when it fails; channel.php is given below in full, and since it has no output block, it is only written to disk.
<?php // channel.php — helpers that classify which channel a problem is heard through // Level names of caught warnings collect here; the message text is not kept. $GLOBALS['heard_warnings'] = []; set_error_handler(function (int $level): bool { $names = [E_WARNING => 'E_WARNING', E_NOTICE => 'E_NOTICE', E_DEPRECATED => 'E_DEPRECATED']; $GLOBALS['heard_warnings'][] = $names[$level] ?? 'E_' . $level; return true; }); // Turns a value into a short text together with its type. function show(mixed $value): string { return match (true) { $value === null => 'null', is_bool($value) => $value ? 'true' : 'false', is_array($value) => 'array(' . count($value) . ')', default => get_debug_type($value) . ' ' . $value, }; } // Runs the job and asks, in order, which channel the problem is heard through: // first a throw, then a warning, then a false/null return; if none of them, silent. function listen(callable $job): array { $GLOBALS['heard_warnings'] = []; try { $result = $job(); } catch (Throwable $error) { return ['halts', get_class($error)]; } if ($GLOBALS['heard_warnings']) { $levels = implode(',', array_unique($GLOBALS['heard_warnings'])); return ['warning', $levels . ', result ' . show($result)]; } if ($result === false || $result === null) { return ['return', 'result ' . show($result)]; } return ['silent', 'result ' . show($result)]; } // Runs the code in a separate PHP execution; exit code 255 means execution halted. function run_separately(string $code): array { exec('php -d display_errors=0 -r ' . escapeshellarg($code) . ' 2>&1', $lines, $exit); $channel = $exit === 255 ? 'halts' : 'silent'; return [$channel, 'lines out ' . count($lines) . ', exit ' . $exit]; }
<?php // json.php — problem channels in JSON and string operations require 'channel.php'; $observations = [ 'malformed json' => fn() => json_decode('{broken'), 'valid json' => fn() => json_decode('{"a":1}', true), 'bad encoding' => fn() => json_encode("\xB1"), 'encoding exception' => fn() => json_encode("\xB1", JSON_THROW_ON_ERROR), 'nested implode' => fn() => implode(',', [1, [2, 3]]), 'strlen on array' => fn() => strlen([1, 2]), ]; foreach ($observations as $name => $observe) { [$channel, $evidence] = listen($observe); printf("%-19s%-9s%s\n", $name, $channel, $evidence); }
malformed json return result null valid json silent result array(1) bad encoding return result false encoding exception halts JsonException nested implode warning E_WARNING, result string 1,Array strlen on array halts TypeError
The first line is the course’s malformed json observation: json_decode('{broken') returned null, and no level was reported. The class is return. The third line is the encoding direction of the same channel: encoding an invalid byte sequence returns false. Both say that an error occurred only through the return value.
The fourth line shows the channel changing. When the JSON_THROW_ON_ERROR flag is given, the same problem is thrown as JsonException; the class is halts. The flag moves a problem on the return channel to the exception channel and hands control to the caller: if the caller does not catch it, execution stops. The fifth line is the third channel of string handling: when a nested array is passed to implode, the inner array is converted to the string 'Array' and E_WARNING is reported, that is, warning. The last is the type channel: when strlen is called with an array argument, it throws TypeError and halts.
Text Search and Cutting
strpos returns 0 when the sought text is at the start and false when it is not found at all. This is the same channel and the same trap as array_search in the Array Functions lesson: a loose comparison does not tell the two apart. PHP offers str_contains for this job, which does not require looking at a return value.
<?php // searching.php — string search and cutting functions $text = 'php lesson'; echo 'strpos at start: ', var_export(strpos($text, 'php'), true), "\n"; echo 'strpos missing: ', var_export(strpos($text, 'js'), true), "\n"; echo 'loose at start: ', (strpos($text, 'php') == false ? 'not found' : 'found'), "\n"; echo 'str_contains at start: ', var_export(str_contains($text, 'php'), true), "\n"; echo 'substr: ', var_export(substr($text, 4), true), "\n"; echo 'substr beyond: ', var_export(substr($text, 20), true), "\n"; echo 'explode: ', json_encode(explode(' ', $text)), "\n"; echo 'empty separator: ', json_encode(explode(',', '')), "\n"; echo 'trim: ', var_export(trim(" a b \n"), true), "\n"; echo 'str_replace: ', str_replace('php', 'go', $text), "\n";
strpos at start: 0 strpos missing: false loose at start: not found str_contains at start: true substr: 'lesson' substr beyond: '' explode: ["php","lesson"] empty separator: [""] trim: 'a b' str_replace: go lesson
strpos returned 0 for the sought text at the start, and the loose comparison counted it as not found; str_contains answered the same question correctly with true. substr returns an empty string if the start is beyond the end of the string and gives no error. When explode splits an empty string, it gives not an empty list but a list with a single empty string: the one-element [""]. This result is a boundary condition, and it silently erases the distinction between “no data” and “empty data present”.
AS14. str_contains, str_starts_with and their relatives do not resolve the problem as a channel; they only turn the question into the right type. Writing a usage guide is not the job of this lesson, and no claim is made about behaviors that do not appear in the table above.
A function that cuts a multibyte letter at the byte level produces an invalid string, and that string causes a problem at the next encoding call. Converting to uppercase depends on the same byte and character distinction.
<?php // cut.php — the effect of byte-level cutting and uppercasing $name = 'señor'; $byteCut = substr($name, 0, 3); $characterCut = mb_substr($name, 0, 3); echo 'byte cut length: ', strlen($byteCut), "\n"; echo 'byte cut valid: ', var_export(mb_check_encoding($byteCut, 'UTF-8'), true), "\n"; echo 'byte cut json: ', var_export(json_encode($byteCut), true), "\n"; echo 'character cut length: ', mb_strlen($characterCut), "\n"; echo 'character cut valid: ', var_export(mb_check_encoding($characterCut, 'UTF-8'), true), "\n"; echo 'uppercase same: ', var_export(strtoupper($name) === mb_strtoupper($name), true), "\n";
byte cut length: 3 byte cut valid: false byte cut json: false character cut length: 3 character cut valid: true uppercase same: false
substr($name, 0, 3) took three bytes and cut off the first byte of the letter ñ; the result is not a valid UTF-8 string, and json_encode rejected it by returning false. mb_substr made the same cut on a character boundary, and the result stayed valid. strtoupper uppercases only ASCII letters; mb_strtoupper converts the multibyte letter as well, so the two results did not come out equal. The choice between bytes and characters in a cutting or uppercasing function announces the problem through no channel: the cut string is noticed only afterwards, at the encoding stage, through a return value.
The Limits of Format Specifiers
sprintf matches the specifiers in the format string with the arguments; the number, type and syntax of the specifiers determine the channel of the call.
<?php // specifiers.php — the limits of sprintf specifiers require 'channel.php'; $observations = [ 'width and precision' => fn() => sprintf('%05.1f|%-4s|', 3.14159, 'ab'), 'pad character' => fn() => sprintf("%'*8s", 'php'), 'signed number' => fn() => sprintf('%+d', 5), 'string as number' => fn() => sprintf('%d', 'abc'), 'missing argument' => fn() => sprintf('%s %s', 'a'), 'unknown specifier' => fn() => sprintf('%y', 1), ]; foreach ($observations as $name => $observe) { [$channel, $evidence] = listen($observe); printf("%-21s%-9s%s\n", $name, $channel, $evidence); }
width and precision silent result string 003.1|ab | pad character silent result string *****php signed number silent result string +5 string as number silent result string 0 missing argument halts ArgumentCountError unknown specifier halts ValueError
The first three lines show the specifiers working normally and are silent. The fourth line is the real point: the %d specifier converts a non-numeric string to 0 and reports no level; data of the wrong type is lost silently in formatting. When there are fewer arguments than specifiers, ArgumentCountError is thrown, and when an undefined specifier is written, ValueError is thrown; both halt. The same function is silent for one kind of error and halting for another: the channel depends on the kind of error in the call.
Padding, Number Format and Comparison
A few helper functions used to display numbers and strings move details such as alignment and separators into their arguments.
<?php // helpers.php — padding, number format and string comparison require 'channel.php'; echo number_format(1234567.891, 2, ',', '.'), "\n"; echo number_format(0.5), ' ', number_format(1.5), ' ', number_format(2.5), "\n"; echo str_pad('7', 3, '0', STR_PAD_LEFT), "|", str_pad('ab', 6, '-', STR_PAD_BOTH), "|\n"; echo ucwords('php string handling'), "\n"; echo strrev('abc'), ' ', str_word_count('one two three'), "\n"; echo 'strcmp: ', strcmp('a', 'b'), ' ', strcmp('b', 'a'), ' ', strcmp('a', 'a'), "\n"; echo 'spaceship: ', 'a' <=> 'b', ' ', 'b' <=> 'a', ' ', 'a' <=> 'a', "\n"; $observations = [ 'numeric loose equal' => fn() => '10' == '1e1', 'numeric strict equal' => fn() => '10' === '1e1', 'leading numeric' => fn() => '12abc' + 1, 'wholly non-numeric' => fn() => 'abc' + 1, 'integer cast' => fn() => (int) '12abc', ]; foreach ($observations as $name => $observe) { [$channel, $evidence] = listen($observe); printf("%-22s%-9s%s\n", $name, $channel, $evidence); }
1.234.567,89 1 2 3 007|--ab--| Php String Handling cba 3 strcmp: -1 1 0 spaceship: -1 1 0 numeric loose equal silent result true numeric strict equal return result false leading numeric warning E_WARNING, result int 13 wholly non-numeric halts TypeError integer cast silent result int 12
number_format rounds at the decimal (1 2 3 for 0.5, 1.5, 2.5), and the thousands and decimal separators are set with arguments. str_pad pads on the left, on the right or on both sides. strcmp and <=> give the same three values for sorting.
The main point to watch in comparison is numeric strings: '10' == '1e1' is true because both sides are converted to numbers, whereas === gives false. '12abc' + 1 produces 13 but gives E_WARNING, and 'abc' + 1 stops with TypeError. (int) '12abc' silently gives 12; for the same input, conversion is silent, arithmetic gives a warning, and arithmetic on a wholly non-numeric string halts.
The Bounding Measurement
The return channel cannot tell cases apart. Decoding a valid null value and decoding malformed input give the same value.
<?php // distinguish.php — a null return can come from two separate cases $broken = json_decode('{broken'); echo 'broken: ', var_export($broken, true), ' error ', json_last_error(), "\n"; $valid = json_decode('null'); echo 'valid null: ', var_export($valid, true), ' error ', json_last_error(), "\n"; echo 'equal: ', var_export($broken === $valid, true), "\n"; echo 'error state: ', json_last_error() === JSON_ERROR_NONE ? 'none' : 'present', "\n"; try { json_decode('{broken', false, 512, JSON_THROW_ON_ERROR); echo "broken: did not throw\n"; } catch (JsonException $e) { echo 'broken: ', get_class($e), ' code ', $e->getCode(), "\n"; } $result = json_decode('null', false, 512, JSON_THROW_ON_ERROR); echo 'valid null, with flag: ', var_export($result, true), "\n";
broken: NULL error 4 valid null: NULL error 0 equal: true error state: none broken: JsonException code 4 valid null, with flag: NULL
Both calls returned null and came out equal under ===. The distinction is made by json_last_error(): four (JSON_ERROR_SYNTAX) for malformed input, zero for a valid null. This function carries the error state of the last call; the next json_decode call resets it, so it must be read immediately afterwards. The more robust route is the JSON_THROW_ON_ERROR flag: malformed input throws JsonException, a valid null comes back as null, and the two cases now arrive through separate channels.
AS15. json_last_error() holds a global error state, and the lesson reads it only right after a json_decode call. Cases where another function intervenes are not measured. The lesson relies on no ini value; the JSON_THROW_ON_ERROR flag is passed to the call explicitly.
Summary
sprintfandprintfplace values according to a format string; the width specifier counts bytes, not characters.strlen('señor')gives six andmb_strlen('señor')gives five; before data that contains multibyte letters is placed in an aligned column, the width must be calculated in characters.json_decode('{broken')returnsnull; the measuring class of this lesson is return (malformed json). For an invalid byte sequence,json_encodereturnsfalse.JSON_THROW_ON_ERRORthrows the same problem asJsonException; the class is halts.- A valid
nulland malformed JSON both returnnull; the distinction is made byjson_last_error()(zero or four) or byJSON_THROW_ON_ERROR. implodeon a nested array gives'Array'andE_WARNING;strlenwith an array argument throwsTypeError.
Next Step
Problems in arrays and strings can be heard through four channels: silent, return, warning and halts. The next topic is the place where data comes not from inside the code but from outside. The Superglobals lesson measures which channel is heard when request data is missing.
To keep your progress and take notes, Log in
My notes
Log in to take notes.