PHP Strings: Quoting, Interpolation, Encoding, and String Functions

Understand every way PHP lets you author a string literal — single/double quotes, heredoc, nowdoc — how interpolation and escaping work in each, the difference between bytes and characters under UTF-8, and the core string functions you'll use every day.

Strings are PHP's workhorse type: request input, database rows, HTML output, and file contents all pass through strings constantly. PHP gives you four distinct ways to write a string literal, each with different rules about interpolation and escaping, and a large standard library of string functions — some byte-oriented, some multibyte-aware — that behave differently on non-ASCII text. Choosing the right quoting style and the right function (mb_* or not) is a small decision that has outsized correctness consequences once your application handles real-world international text.

Quoting Styles: Single, Double, Interpolation, and Escaping

Single-quoted strings ('...') are the simplest and fastest: PHP does no interpolation inside them at all, and recognizes only two escape sequences, \\ (backslash) and \' (a literal single quote) — everything else, including \n and \t, is left completely literal (so 'line1\nline2' prints as the literal six characters \n, not a newline). Double-quoted strings ("...") support variable interpolation directly ("Hello, $name!") and a much larger set of escape sequences: \n (newline), \t (tab), \r (carriage return), \", \$, \\, and Unicode/hex/octal escapes like \u{1F600}.

Simple interpolation drops a variable directly into a double-quoted string ("$name is $age"), but it only reaches one level of array/property access without extra syntax ("$user->name" works, "$users[0]" works for a simple numeric or unquoted string key). For anything more complex — a method call, a chained property access, or an array key that needs quotes — use 'complex' (curly brace) interpolation: "{$user->getFullName()}" or "{$config['database']['host']}". Wrapping the expression in { } tells the parser exactly where the expression starts and ends, avoiding ambiguity.

Concatenation with the . operator is the alternative to interpolation and is often clearer for building up longer strings piece by piece, especially when mixing function calls: $message = 'Hello, ' . strtoupper($name) . '!'; — you cannot call a function directly inside simple interpolation, so concatenation (or the {} form calling a variable holding a closure) is required there.

• 'It\'s here' — single quotes only need escaping for \' and \\. • "Total: $10" prints literally "Total: $10" only if $10 isn't a valid variable name reference — but "Total: \$10" is the safe, explicit way to show a literal dollar sign. • "{$order->items[0]->name}" — curly-brace interpolation for chained/complex expressions. • $greeting = 'Hi, ' . $name . '! You have ' . $count . ' new messages.'; — concatenation for multi-part strings.

Heredoc and Nowdoc

Heredoc syntax (<<<EOT ... EOT;) lets you write a multi-line string that behaves like a double-quoted string — full interpolation and escape sequences — without having to escape every internal quote character, which makes it ideal for embedding chunks of HTML or long templated text. The closing identifier must appear on its own line (as of PHP 7.3+, it can also be indented to match the surrounding code's indentation, and PHP strips that common indentation from every line of the heredoc body — a huge readability improvement over the old rule that the closing marker had to start at column 0).

Nowdoc syntax (<<<'EOT' ... EOT; — note the single quotes around the opening identifier) is heredoc's non-interpolating sibling: it behaves like a single-quoted string, doing zero interpolation and zero escape processing, while still supporting the multi-line, quote-free convenience. Use nowdoc when you want to embed a large literal block of text — a code sample, a regular expression, JSON with $ signs in it — that must not be touched by PHP's parser at all.

Both heredoc and nowdoc are commonly used for SQL query strings and HTML fragments in templating code, because they let you write text naturally across many lines without a wall of escaped quotes and concatenation dots.

• $html = <<<HTML\n <div class="card">{$title}</div>\n HTML; — interpolating, indentation-stripped (PHP 7.3+). • $raw = <<<'TEXT'\n No $interpolation happens in here at all.\n TEXT; — nowdoc, fully literal. • The closing identifier line must contain only the identifier (plus optional ; and whitespace/indentation matching the opening).

Offsets, Bytes vs. Characters, and Multibyte Strings

Strings can be indexed like arrays using square brackets: $str[0] gives the first byte, $str[-1] (PHP 7.1+) gives the last. Crucially, this indexing — and most of PHP's classic string functions like strlen(), substr(), and strpos() — operate on *bytes*, not on human-perceived characters. In ASCII text every character is exactly one byte, so the distinction is invisible, but in UTF-8 (PHP's de facto standard encoding for modern applications) a single character like 'é' or an emoji can occupy two, three, or four bytes, so strlen('café') returns 5 (the 'é' is two bytes in UTF-8), not the 4 characters a human sees.

This is exactly the problem the mb_* (multibyte) function family solves. mb_strlen('café', 'UTF-8') correctly returns 4, because it counts encoded characters, not raw bytes. The same pairing applies across the string function library: use mb_substr() instead of substr(), mb_strpos() instead of strpos(), mb_strtoupper()/mb_strtolower() instead of the byte-oriented strtoupper()/strtolower() (which only correctly uppercase ASCII letters and leave accented characters untouched), whenever the string might contain non-ASCII text — which, for any application handling real names, addresses, or user-generated content, is essentially always.

The practical rule for modern PHP: default to the mb_* functions for anything that touches user-facing or international text, and reserve the plain byte-oriented functions for cases where you specifically know you're working with binary data or guaranteed-ASCII content (like an internal machine-generated token).

• strlen('café') === 5 (bytes) vs mb_strlen('café', 'UTF-8') === 4 (characters). • $str[0] and substr() index/slice by byte offset — can split a multibyte character in half if used carelessly on UTF-8 text. • mb_substr($str, 0, 4, 'UTF-8') safely slices by character count instead of byte count. • mb_internal_encoding('UTF-8'); sets the default encoding so you don't have to pass it to every mb_* call.

Core String Functions

strlen($s) returns the byte length; strpos($haystack, $needle) returns the byte offset of the first occurrence of $needle (or false if not found — always compare with === since a valid match at offset 0 is falsy-looking but not equal to false). PHP 8 added str_contains($haystack, $needle), str_starts_with(), and str_ends_with(), which return clean booleans and finally removed the long-standing need to write the awkward strpos($h, $n) !== false idiom just to check containment.

str_replace($search, $replace, $subject) performs a literal (non-regex) search-and-replace and accepts arrays for bulk replacements; trim($s) (and ltrim/rtrim) strip whitespace — or a custom character set passed as a second argument — from the ends of a string. explode($delimiter, $string) splits a string into an array on a literal delimiter, and implode($glue, $array) (aliased as join()) does the reverse, joining an array's elements into one string.

sprintf($format, ...$args) builds a formatted string using printf-style placeholders (%s string, %d integer, %.2f float with 2 decimals, %05d zero-padded to 5 digits) and is the standard tool for anything that needs precise formatting, like currency (sprintf('$%.2f', $price)) or padded IDs (sprintf('ORD-%05d', $orderId)). printf() is identical but prints directly instead of returning the string.

• if (str_contains($email, '@')) { /* ... */ } — clean PHP 8 containment check. • $parts = explode(',', 'a,b,c'); // ['a', 'b', 'c'] • implode(' - ', ['2024', '01', '15']); // '2024 - 01 - 15' • sprintf('%05d', 42); // '00042' • strpos('hello', 'h') === 0 — must use === false, not == false, to test for 'not found'.

Quoting styles side by side

Heredoc's closing identifier can be indented to match the code (PHP 7.3+); nowdoc's opening identifier is wrapped in single quotes. Prefer mb_* functions whenever the string may contain non-ASCII (UTF-8 multibyte) characters. Compare strpos()/similar 'position' functions with === false, never == false.

<?php
$name = 'Ada';

// single-quoted: no interpolation, minimal escaping
echo 'Hello, $name!';          // Hello, $name!

// double-quoted: interpolation + full escapes
echo "Hello, $name!\n";        // Hello, Ada! (with a real newline)
echo "Nested: {$user->email}"; // curly-brace form for complex expressions

// heredoc: interpolating, multi-line, no quote-escaping needed
$html = <<<HTML
    <p>Welcome, {$name}!</p>
    HTML;

// nowdoc: literal, multi-line, no quote-escaping needed
$template = <<<'TXT'
    Use {$placeholder} literally, it will NOT be replaced.
    TXT;

Code Examples

'café' is 5 bytes in UTF-8 (the 'é' takes 2 bytes) but only 4 characters. strlen() and substr() operate byte-by-byte and can silently corrupt multibyte characters by slicing through the middle of one; the mb_* equivalents operate on whole characters and are the correct choice for any text that isn't guaranteed pure ASCII. Expected result: 5 4 caf caf sprintf's format specifiers give precise, aligned formatting that plain concatenation can't easily match (zero-padding, fixed-width left alignment, fixed decimal places), while the heredoc block interpolates the same array values naturally across multiple lines without any concatenation dots. Expected result: ORD-00042 | Grace Hopper | $ 129.50 Receipt for Grace Hopper Order: ORD-42 Total: $129.5 sprintf('ORD-%05d | %-20s | $%7.2f',...) — %05d zero-pads, %-20s left-aligns in a 20-char field, %7.2f right-aligns a 2-decimal float in 7 chars.

<?php
mb_internal_encoding('UTF-8');

$word = 'café';

echo strlen($word) . "\n";      // byte count
echo mb_strlen($word) . "\n";   // character count

echo substr($word, 0, 3) . "\n";     // may cut a multibyte char in half
echo mb_substr($word, 0, 3) . "\n";  // correctly keeps whole characters
<?php
$order = ['id' => 42, 'customer' => 'Grace Hopper', 'total' => 129.5];

$line = sprintf('ORD-%05d | %-20s | $%7.2f', $order['id'], $order['customer'], $order['total']);
echo $line . "\n";

$receipt = <<<TXT
    Receipt for {$order['customer']}
    Order: ORD-{$order['id']}
    Total: \${$order['total']}
    TXT;
echo $receipt;

Bytes vs characters in UTF-8

Any non-ASCII character can span multiple bytes in UTF-8. Byte-oriented functions (strlen, substr, strpos) don't know this; mb_* functions do.

'café'  ->  c  a  f  é
bytes:      1  1  1  [2 bytes: 0xC3 0xA9]   = 5 bytes total
chars:      1  1  1  1                      = 4 characters total

strlen()    counts BYTES  -> 5
mb_strlen() counts CHARS  -> 4

Important Rules

Use single quotes for literal text with no variables; use double quotes (or heredoc) when you need interpolation.

Use curly-brace interpolation {$expr} for anything beyond a bare $variable — property access, array keys, method calls.

Default to mb_* functions (mb_strlen, mb_substr, mb_strpos, mb_strtoupper) for any text that might contain non-ASCII characters.

Compare the result of strpos()/mb_strpos() with === false (or use str_contains() in PHP 8+), never == false.

Reach for sprintf() whenever output needs fixed-width, padded, or decimal-precise formatting.

Where this shows up

Any form that accepts names, addresses, or free text from international users will contain multibyte UTF-8 characters, and using byte-oriented functions on that data is one of the most common sources of subtle text-corruption bugs in PHP applications — truncated names, broken search matches, and mis-measured 'characters remaining' counters all typically trace back to strlen()/substr() where mb_strlen()/mb_substr() was needed.

if (strpos($text, 'error') == false) { /* treats offset 0 as 'not found' too */ } if (!str_contains($text, 'error')) { /* correct in PHP 8+ */ } strpos() returns the integer offset of the match, and a match at position 0 is a valid 'found' result — but 0 == false is true under loose comparison, so the old idiom silently misreports a match at the very start of the string. str_contains() sidesteps this entirely by returning a real boolean.

$snippet = substr($utf8Text, 0, 10); // may cut a multibyte character in half, producing broken text

$snippet = mb_substr($utf8Text, 0, 10); // slices by whole characters substr() counts bytes, so on UTF-8 text it can split a multi-byte character across the cut point, producing invalid, mangled output. mb_substr() understands character boundaries and never does this.

What does strlen('café') return, and why does it differ from mb_strlen('café')? A. Both return 4; they are identical functions B. strlen returns 5 (bytes) because 'é' takes 2 bytes in UTF-8; mb_strlen returns 4 (characters) C. strlen returns 4; mb_strlen returns 5 D. strlen throws an error on non-ASCII input

B. strlen returns 5 (bytes) because 'é' takes 2 bytes in UTF-8; mb_strlen returns 4 (characters) — strlen() counts raw bytes, and the accented 'é' occupies 2 bytes in UTF-8, giving 5 total bytes for 4 visible characters. mb_strlen() is encoding-aware and correctly counts 4 characters.

In a double-quoted string, which syntax is required to interpolate a chained expression like an array's nested key? A. Plain $variable syntax works for any expression B. Curly-brace syntax: "{$config['db']['host']}" C. You cannot interpolate array access at all D. Only heredoc supports nested array interpolation

B. Curly-brace syntax: "{$config['db']['host']}" — Simple interpolation only reliably handles a bare variable or a single-level property/array access; anything more complex, like a nested array key, needs the curly-brace complex syntax to disambiguate where the expression starts and ends.

What is the key difference between heredoc and nowdoc syntax? A. Heredoc is multi-line, nowdoc is single-line B. Heredoc interpolates variables like a double-quoted string; nowdoc does not interpolate, like a single-quoted string C. They are functionally identical, just different syntax preferences D. Nowdoc can only contain numbers

B. Heredoc interpolates variables like a double-quoted string; nowdoc does not interpolate, like a single-quoted string — Both are multi-line, quote-free string syntaxes, but heredoc (<<<EOT) behaves like double quotes (interpolation + escapes) while nowdoc (<<<'EOT') behaves like single quotes (fully literal, no interpolation).

Write a function truncate(string $text, int $maxChars): string that returns the first $maxChars characters of $text (correctly handling multibyte UTF-8 text) followed by '...' only if the text was actually longer than $maxChars.

function truncate(string $text, int $maxChars): string { if (mb_strlen($text) <= $maxChars) { return $text; } return mb_substr($text, 0, $maxChars) . '...'; }

Use mb_strlen() to check the original length, and mb_substr() to safely cut the string on a character boundary rather than a byte boundary.