Bytes vs Characters: strlen, mb_*, and the UTF-8 Trap
A PHP string is a byte sequence — there is no character type and no encoding attached. You spent the previous topic reshaping arrays with Array Functions for Data Transformation; text is the other half of real-world PHP input, and it fails in ways arrays never do. strlen counts bytes, which is correct for sizes and wrong for text:
strlen('café') // 5 — é is two bytes in UTF-8
mb_strlen('café', 'UTF-8') // 4 — four characters
strlen('日本語') // 9 — three bytes per character
Anywhere the text matters — truncating for display, counting for validation, slicing by index — you want the mb_* family, which counts and moves by characters under a declared encoding:
mb_substr('café au lait', 0, 4, 'UTF-8') // 'café' — a clean cut
substr('café au lait', 0, 4) // "caf\xC3" — half a character, invalid UTF-8
That second line is the classic production bug: a byte-level cut that lands inside a multi-byte sequence produces invalid UTF-8, and from there the symptoms cascade — the browser renders a replacement character, json_encode returns false (unless JSON_INVALID_UTF8_SUBSTITUTE), and a downstream mb_* call with substitute mode quietly eats more text than you expected.
The mb_* equivalents you actually use day to day:
mb_strtolower('STRASSE', 'UTF-8') // case handling by character
mb_strtoupper($s, 'UTF-8')
mb_strpos('naïve café', 'café') // character offset, not byte offset
mb_str_split($s, 20, 'UTF-8') // chunk by characters (7.4+)
mb_convert_encoding($s, 'UTF-8', 'Windows-1252') // legacy import cleanup
Two configuration details save repetition. mb_internal_encoding('UTF-8') (or the mbstring.internal_encoding ini) sets the default so calls can omit the encoding argument; modern frameworks set UTF-8 once at bootstrap. And the mb_ prefix is not magic — it is an extension (php-mbstring), enabled by default in most distributions and in the official Docker images, but absent from some minimal builds. function_exists('mb_substr') guards are the polite pattern for libraries that must run anywhere.
One honest limit of byte-vs-character thinking: characters are not always what users mean either. é can be one code point (U+00E9) or an e plus a combining accent (U+0065 U+0301) — two "characters" that render identically. Emoji are worse: 👩💻 is three code points joined by zero-width joiners. For user-perceived units, the intl extension's grapheme functions are the right tool (grapheme_substr, grapheme_strlen). The pragmatic ladder: strlen for bytes, mb_* for code points, grapheme_* for what a human would point at.
Two more mb_* functions earn permanent slots. mb_strcut is the truncation function you actually want for display previews: it cuts by byte offset but never splits a character, so it respects a byte budget (the realistic constraint for HTML previews and log lines) while returning valid UTF-8:
mb_strcut($article, 0, 160, 'UTF-8') . '…'
mb_substr counts characters instead — pick it when the requirement is "first 40 characters", pick mb_strcut when the requirement is "first 160 bytes worth of text". And mb_convert_case handles casing rules that go beyond strtoupper:
mb_convert_case('straße', MB_CASE_UPPER, 'UTF-8') // 'STRASSE' — locale rules, not byte flipping
mb_convert_case('hello world', MB_CASE_TITLE, 'UTF-8') // 'Hello World'
Contrast that with the byte-level ucfirst, which has no multibyte variant and mangles non-ASCII input — a difference that becomes visible the moment a user's name contains anything outside ASCII.
Two pitfalls live in the details of these functions, and both have shipped bugs to production.
Case conversion changes length. German ß uppercases to the two-character SS:
mb_strlen('straße', 'UTF-8') // 6 characters (7 bytes — ß is 2 bytes)
mb_strtoupper('straße', 'UTF-8') // 'STRASSE' — SEVEN characters from six
mb_strlen(mb_strtoupper('straße', 'UTF-8'), 'UTF-8') // 7 — the string GREW during conversion
mb_strtolower('İ', 'UTF-8') // 'i̇' — U+0069 U+0307, two code points from one
Any "validate then store" pipeline that checks a length constraint before a case transform can find the stored value exceeding the column limit afterwards — a VARCHAR(6) column holds straße exactly, then refuses STRASSE after an import normalizes case. And the dotted capital İ (U+0130) shows the other direction: PHP's full-case mapping turns one code point into two, so a string can grow without a single character visibly changing in your editor.
Validate input at the boundary with mb_check_encoding. Trusting that a request body is UTF-8 is exactly the assumption that breaks: an uploaded CSV exported from a Windows-1252 spreadsheet arrives with 0x93 quote bytes that are invalid UTF-8, and every downstream mb_* call with substitute mode silently replaces them with ? — corruption that is invisible until a customer complains about mangled product names.
if (! mb_check_encoding($raw, 'UTF-8')) {
$raw = mb_convert_encoding($raw, 'UTF-8', 'Windows-1252'); // declare the source encoding
}
One mb_check_encoding at the import or upload boundary replaces a debugging session that otherwise spans three layers: the mangled bytes, the json_encode failure they cause, and the database column that happily stores either version under a utf8mb4 collation.
Cut, Search, Replace: substr, strpos, str_replace
substr extracts a section by byte offset, and its negative-offset forms are the reason people reach for it constantly:
substr('order-2026-0042', 0, 5) // 'order' — start, length
substr('order-2026-0042', -4) // '0042' — last 4 bytes
substr('order-2026-0042', 6, -1) // '2026-004' — to 1 before the end
substr_replace($s, 'X', 0, 5) // replace a slice, not a match
For text, as the previous chapter established, mb_substr is the same function that respects characters.
strpos finds a needle's offset — or returns false:
$pos = strpos($haystack, 'needle');
if ($pos === false) { … } // STRICT: position 0 is falsy, 'needle' at 0 is legitimate
The === false discipline is the same one array_search demands, and getting it wrong is a review-blocking bug: if (! strpos(...)) treats "found at position 0" as "not found". PHP 8.0 retired most of these calls for the boolean questions:
str_contains($s, 'needle') // anywhere (empty needle → true)
str_starts_with($s, 'order-') // prefix
str_ends_with($s, '.csv') // suffix — the case everyone reimplemented wrong
All three are byte-level but exact for ASCII needles, need no offset arithmetic, and never return false-vs-0 ambiguity — which is why they are the default in modern code, with strpos(...) !== false reserved for when you need the position.
Two edge cases before you rely on them everywhere. An empty needle makes str_contains return true — a filter like str_contains($name, $userQuery) passes for a blank search box, which is usually the intent but occasionally a security hole when the "needle" comes from a request the server did not validate. And the functions are byte-level, which is harmless for exact multibyte matches (UTF-8 byte sequences for the same text are identical) but not for case-insensitive or normalized ones: str_contains('İSTANBUL', 'istanbul') is false, and the correct tools are mb_stripos($s, $needle, 0, 'UTF-8') !== false for case folding or Normalizer::normalize($s, Normalizer::FORM_KC) when two inputs render identically but encode differently.
str_replace performs global substitution, and its three-array forms cover most call sites:
str_replace('-', '_', $slug) // one search, one replacement
str_replace(['a','b'], ['A','B'], $s) // parallel arrays, applied in order
str_replace(['a','b'], 'X', $s) // many → one
str_ireplace('error', 'warning', $log) // case-insensitive (careful with names)
Searches apply left-to-right and do not rescan replacements, so overlapping patterns behave predictably. It is byte-based and case-sensitive; for pattern-aware substitution (regex, case folding, boundaries) the preg_* functions of the later topic take over.
A fourth argument exists and is worth knowing for observability: str_replace($search, $replace, $s, $count) reports how many substitutions happened, which turns a redaction helper from a hope into a check:
$clean = str_replace($secretTokens, '[redacted]', $log, $n);
if ($n > 0) { logger->warning("redacted {n} tokens", ['n' => $n]); }
When several patterns could overlap, strtr is the sharper tool: given an array it translates in one pass, matches the longest key at each position, and never re-replaces text it just produced:
strtr($s, ['ab' => 'X', 'a' => 'Y']) // 'ab...' → 'X...' — 'ab' wins, result keeps no 'X' rescanned
str_replace(['ab', 'a'], ['X', 'Y'], $s) // sequential: 'ab' → 'X' but 'a' still runs after
That difference — longest-match single pass versus ordered sequential passes — is exactly the kind of detail that decides whether a profanity filter eats half a word or a transliterator double-applies its own output. strtr also accepts a from/to character-pair string for classic tr-style transliteration.
The formatting and padding helpers round out the cutlery:
str_repeat('─', 40) // a rule for CLI output
str_pad($row, 12) // pad right to width 12 (default), STR_PAD_LEFT for columns
wordwrap($text, 72, "\n") // wrap at word boundaries
trim(" padded \n") // strip whitespace; trim($s, "0.") strips a custom charlist
lcfirst, ucfirst, ucwords // case touch-ups that do not exist in mb_* form…
That last row hides a real trap: ucfirst and lcfirst have no multibyte variants, so they misbehave on non-ASCII input (they are byte-level). Non-ASCII case manipulation belongs to mb_convert_case with MB_CASE_TITLE and friends.
explode, implode, and Parsing Text You Did Not Write
Delimited text crosses every boundary — CSV exports, header lists, config values, log lines — and PHP's two conversion functions handle both directions:
$parts = explode(',', 'php,arrays,interview'); // ['php','arrays','interview'] — string → array
$line = implode(' | ', $parts); // 'php | arrays | interview' — array → string
explode details that matter in real input:
explode(',', '', 2) // [] — empty input gives an empty array, not ['']
explode(',', 'a,,b') // ['a', '', 'b'] — empty segments survive; filter them out
explode(',', 'a,b,c', 2) // ['a', 'b,c'] — LIMIT 2 caps the split; the rest stays intact
explode(',', $s, -2) // negative limit: all but the last N pieces are split off
The limit argument is the one that saves headers and addresses: splitting To: a@x.com, b@y.com on ', ' with a limit of 2 keeps a trailing comma-group whole instead of shredding every recipient. And the empty-segment behavior pairs naturally with the previous topic: array_filter(explode(...)) or array_map('trim', explode(...)) is the standard "clean the pieces" preamble.
When does explode stop being enough? The moment the delimiter is a pattern rather than a literal. explode finds one exact byte sequence; preg_split applies a regex, plus flags that shape the result:
preg_split('/\s*,\s*/', 'php, arrays ,interview'); // ['php','arrays','interview'] — separator + surrounding spaces gone
preg_split('/[,\n;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY); // any of three separators, empties dropped
preg_split('/(\d+)/', 'a1b22c', -1, PREG_SPLIT_DELIM_CAPTURE); // ['a','1','b','22','c'] — delimiters kept
The decision rule is performance and honesty, not preference: a literal separator means explode — it is a single-pass byte scan with no pattern engine, and it makes the fixed-separator assumption visible to the next reader. preg_split('/,/') says "I might upgrade this to a pattern someday" and pays for the engine now. Only reach for the regex when the input genuinely admits many shapes ([,\n;]+ above), and remember PREG_SPLIT_OFFSET_CAPTURE when you need each piece's position for error reporting.
When the delimited text is actually CSV, do not use explode. Real CSV has quoted fields that may contain the delimiter and newlines:
name,note
"Ada","ships, invoices, and support"
explode(',', $row) turns that into four broken fields. str_getcsv($row) (or fgetcsv($fh) for a file handle) parses per RFC 4180 — quotes, embedded commas, escaped quotes — with a configurable delimiter, enclosure, and escape:
while (($row = fgetcsv($fh)) !== false) {
[$name, $note] = $row; // correctly: 'Ada', 'ships, invoices, and support'
}
The interview framing writes itself: "how would you parse this CSV?" — fgetcsv in a loop, never string splitting, because the moment a note field contains a comma or a newline (and it will), naive splitting corrupts data silently.
The fgetcsv signature has knobs worth knowing when the data is not-quite-RFC-4180:
fgetcsv($fh, 0, ';', '"', '\\'); // length 0 (unlimited), ';'-separated, custom escape
and one gotcha that produces phantom rows in every import: a blank line comes back as [null] — a one-element array holding null, not an empty array and not false (which means end-of-file). The defensive loop shape filters it explicitly:
while (($row = fgetcsv($fh)) !== false) {
if ($row === [null]) { continue; } // skip blank lines
…
}
For building text, the tools from String Interpolation and Heredoc take over — heredoc for templates, concatenation for fragments — with two functions worth knowing here: sprintf for positional formatting (already met in the output chapter) and implode as the loop-free way to join an array, replacing the foreach { $out .= $x . ','; } idiom including its trailing-separator bug. And concatenation performance, asked surprisingly often: PHP optimizes $s .= $piece in a loop; a million appends are linear and fine. What is not fine is re-scanning the growing string inside the same loop — that is the O(n²) shape to avoid, and it is fixable by collecting pieces and calling implode once at the end.
Quiz
1. What does `substr('café menu', 0, 4)` return and why does it matter?
2. Why is `if (! strpos($haystack, $needle))` a bug?
3. A CSV row is `"Ada","ships, invoices"`. What does explode(',', $row) do versus str_getcsv($row)?
4. What is the effect of explode(',', 'a,b,c,d', 2)?
Flashcards
Question
strlen vs mb_strlen — what does each count and what is the default trap?
Click to reveal answer
Answer
strlen counts bytes (strlen('café') = 5); mb_strlen counts characters under an encoding (mb_strlen('café', 'UTF-8') = 4). The trap is using byte functions on text: truncation with substr can split a multi-byte character, producing invalid UTF-8, mojibake, and json_encode failures.
Question
substr negative offsets, and mb_substr's role
Click to reveal answer
Answer
substr($s, -4) = last 4 bytes; substr($s, 0, -1) drops the last byte; substr_replace swaps a slice. All are byte-level — for UTF-8 text use mb_substr, which counts characters and never cuts mid-sequence.
Question
The strpos return contract and its modern replacements
Click to reveal answer
Answer
strpos returns the integer offset or false — so only `!== false` is correct ('found at 0' is truthy-false). PHP 8.0 added str_contains, str_starts_with, str_ends_with for the boolean questions; strpos remains for when you need the position.
Question
explode limit argument behavior, and the empty-input rule
Click to reveal answer
Answer
A positive limit caps the pieces: explode(',', 'a,b,c', 2) = ['a','b,c'] (remainder intact); negative limit keeps all but the last N split. Empty string → [] (not ['']), and empty segments in 'a,,b' survive — filter with array_filter. preg_split only when the separator is a regex pattern (e.g. '/\s*,\s*/' eats surrounding spaces); a literal separator means explode.
Question
Why is CSV parsing with explode a data-corruption bug?
Click to reveal answer
Answer
RFC 4180 CSV allows the delimiter inside quoted fields and across lines; explode splits blindly, breaking such rows into wrong fields silently. Use str_getcsv($row) or fgetcsv($fh) — they honor enclosure, embedded commas, and escaped quotes.
Revision Notes
Key Takeaways
- 1. Strings are byte sequences: strlen/substr/strpos count bytes; mb_* counts characters; grapheme_* counts user-perceived units
- 2. Byte-level cuts on UTF-8 produce invalid text — the mojibake/json_encode-false cascade — so text slicing always goes through mb_substr
- 3. strpos returns int|false, making === false the only correct check; PHP 8.0's str_contains/starts_with/ends_with remove the ambiguity
- 4. str_replace is global, order-preserving, and accepts parallel arrays; explode's limit argument keeps the remainder intact, and preg_split earns its cost only for pattern delimiters like '/\s*,\s*/'
- 5. CSV is not delimited text: fgetcsv/str_getcsv parse quotes and embedded commas per RFC 4180; explode corrupts such rows silently
Interview Tips
- • "Truncate a string to 50 characters for display" — mb_substr with UTF-8, mention grapheme_* for emoji-heavy input, and note the mbstring extension must be present
- • "strpos gotcha?" — int-or-false with a legitimate 0; state the === false rule and the three PHP 8.0 replacements; this is a classic screen question
- • "How do you parse CSV in PHP?" — fgetcsv in a loop over the handle; explicitly reject explode because quoted fields contain delimiters and newlines
- • "json_encode returns false on user text — why?" — invalid UTF-8 from a byte-level cut or bad import; diagnose with mb_check_encoding and fix at the boundary (mb_convert_encoding or JSON_INVALID_UTF8_SUBSTITUTE)
Cheat Sheet
String Manipulation Functions
Bytes vs characters — the central fact:
strlen('café') // 5 bytes
mb_strlen('café', 'UTF-8') // 4 characters
substr('café…', 0, 4) // cuts mid-character → invalid UTF-8 → json_encode false, mojibake
mb_substr($s, 0, 4, 'UTF-8')
Set mb_internal_encoding('UTF-8') once at bootstrap. strlen for bytes/sizes, mb_* for text, grapheme_* (intl) for user-perceived characters (emoji, combining accents).
Cut & search:
substr($s, -4) // last 4 bytes mb_substr for characters
strpos($h, $n) // offset or false → ALWAYS `=== false`
str_contains($s, $n) // PHP 8.0: anywhere / starts_with / ends_with
str_replace(['a','b'], 'X', $s) // global, in order, no rescanning
str_ireplace(...) // case-insensitive — careful with identifiers
str_repeat('─', 40) str_pad($r, 12) wordwrap($t, 72) trim($s, "0.")
ucfirst/lcfirst are byte-level with no mb_ variant — use mb_convert_case.
String ⇄ array:
explode(',', 'a,b,c') // → ['a','b','c']; '' → []; 'a,,b' keeps the empty piece
explode(',', $hdr, 2) // limit caps the split; rest stays whole
preg_split('/\s*,\s*/', $s) // pattern delimiter: separators + surrounding spaces gone
preg_split('/[,\n;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) // many shapes, empties dropped
implode(' | ', $parts) // ← join, no trailing-separator bug
str_getcsv($row) / fgetcsv($fh) // REAL CSV: quotes, embedded commas, newlines (RFC 4180)
Rule: never explode CSV — quoted fields break it; fgetcsv in a loop is the correct answer. Build strings with implode/heredoc/sprintf; avoid O(n²) re-scanning of a growing string inside a loop.