Skip to content
beginner Phase 3 · Arrays and String Processing

String Manipulation Functions

Cut, search, replace, and format text with core functions.

45m
0 problems
Topic Progress 0%

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.

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?

Question 1 options

2. Why is `if (! strpos($haystack, $needle))` a bug?

Question 2 options

3. A CSV row is `"Ada","ships, invoices"`. What does explode(',', $row) do versus str_getcsv($row)?

Question 3 options

4. What is the effect of explode(',', 'a,b,c,d', 2)?

Question 4 options

Flashcards

Question

strlen vs mb_strlen — what does each count and what is the default trap?

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

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

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

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?

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.

Continue Learning