Single and double quotes

A string is a piece of text. In PHP you write one between single quotes ('…') or double quotes ("…"), and unlike in Python or JavaScript the two are not the same:

  • In single quotes, almost everything is taken literally. Only two escape sequences work: \' for a quote and \\ for a backslash. A $ is just a dollar sign.
  • In double quotes, PHP expands variables ($customer becomes its value) and understands the usual escape sequences: \n new line, \t tab, \", \\, \$ for a literal dollar sign, and \u{20AC} for a Unicode character by its hexadecimal code.
<?php
$customer = 'Amara';
$kwh = 318.4;

echo 'Hello $customer\n', "\n";        // single quotes: literal text
echo "Hello $customer\n";              // double quotes: variables expanded
echo "Usage: {$kwh} kWh\tthis month\n";
echo 'It\'s a backslash: \\', "\n";
echo "Tariff: \u{20AC}0.27 per kWh\n";
Hello $customer\n
Hello Amara
Usage: 318.4 kWh	this month
It's a backslash: \
Tariff: €0.27 per kWh

The first line shows the difference: in single quotes, $customer and \n stay as typed, so the line break comes from the separate "\n". echo accepts several values separated by commas and prints them one after another.

Which to choose? Many teams use single quotes for plain text and double quotes only where they want interpolation or escapes. That way a $ in a price list or a regular expression is never expanded by accident. The speed difference people used to argue about is negligible.

Joining strings with the dot

PHP joins strings with the concatenation operator, a dot. The shorthand .= appends to the string in a variable.

<?php
$ticket = 'INC-31877';
$customers = 412;

// Concatenation with the dot operator
echo 'Outage ' . $ticket . ' affects ' . $customers . ' customers.' . "\n";

// The same text with interpolation
echo "Outage $ticket affects $customers customers.\n";

$message = 'Planned work';
$message .= ' on Monday';            // append to the same variable
echo $message, "\n";
Outage INC-31877 affects 412 customers.
Outage INC-31877 affects 412 customers.
Planned work on Monday

Variables inside strings

Putting values into a string is called string interpolation. PHP has had it for decades, long before JavaScript's template literals or Python's f-strings. Double-quoted strings support two forms:

  • Simple syntax: $name, $meter[id] (an array element, with the key unquoted) or $order->total (an object property).
  • Braces: {$meter['id']}, {$order->customer->name} or {$readings[0]['kwh']}. Everything that starts with {$ and ends with } is a variable expression, so nested arrays, quoted keys and method calls such as {$order->total()} work.
<?php
$meter = ['id' => 'MTR-100231', 'kwh' => 1542.7];
$codes = ['KE', 'NL'];

echo "Meter $meter[id] read $meter[kwh] kWh\n";     // simple syntax: no quotes on the key
echo "Meter {$meter['id']} read {$meter['kwh']} kWh\n"; // braces: any variable expression
echo "Region: $codes[0]-$codes[1]\n";

$unit = 'kWh';
echo "Total: {$meter['kwh']}{$unit}\n";              // braces end the name clearly
echo "Total: $meter[kwh]$unit\n";
echo "Total: 1542.7 $units\n";
Meter MTR-100231 read 1542.7 kWh
Meter MTR-100231 read 1542.7 kWh
Region: KE-NL
Total: 1542.7kWh
Total: 1542.7kWh
Total: 1542.7

The braces also tell PHP where a variable name ends. In "$kwhkWh", PHP would look for a variable called $kwhkWh; "{$kwh}kWh" is unambiguous. The last line of the example shows what happens with a typo: $units does not exist, so PHP inserts an empty string and writes a warning (Warning: Undefined variable $units) to the error output, which is why the line ends in a space.

Only variables are expanded. A function call such as "Total: strtoupper($unit)" prints the text literally; call the function outside the string, or use sprintf().

Heredoc and nowdoc

Heredoc

For text over several lines, such as an email body or a block of SQL, quotes get awkward. A heredoc starts with <<< and a name of your choice (an identifier such as TEXT or SQL), continues on the next line, and ends with the same name on a line of its own. It behaves like a double-quoted string: variables and escape sequences are expanded, but you do not need to escape double quotes.

<?php
$meter = 'KE-0042';
$kwh = 318.4;

$letter = <<<TEXT
    Dear customer,
    Your meter $meter recorded {$kwh} kWh this month.
    Kestrel Energy
    TEXT;

echo $letter, "\n";

$template = <<<'TEXT'
    Use $meter and {$kwh} as placeholders.
      Indented lines keep their extra spaces.
    TEXT;

echo $template, "\n";
Dear customer,
Your meter KE-0042 recorded 318.4 kWh this month.
Kestrel Energy
Use $meter and {$kwh} as placeholders.
  Indented lines keep their extra spaces.

The closing marker may be indented, and PHP removes that much indentation from every line of the text. That is why the letter prints flush left even though it is indented in the code. Lines that are indented further than the marker keep the extra spaces, as the second string shows. The text never includes the final line break before the marker.

Nowdoc

A nowdoc is the single-quoted version: the name after <<< is in single quotes (<<<'TEXT'), and nothing inside is expanded. Use it for text that contains many dollar signs or backslashes, such as a code sample, a shell command or a template with its own placeholders. The second string in the example above is a nowdoc: $meter and {$kwh} stay as typed.

Everyday string functions

PHP has dozens of string functions. They are plain functions that take the string as an argument (strlen($s), trim($s)), not methods on the string as in Python and JavaScript (s.strip(), s.trim()). A handful covers most daily work:

<?php
$line = '  MTR-100231;Deventer;1542.7 kWh  ';

$clean = trim($line);
echo "[$clean]\n";
echo strlen($clean), "\n";
echo strtolower($clean), "\n";

[$meter, $city, $reading] = explode(';', $clean);
echo $city, "\n";
echo substr($meter, 4), ' ', substr($meter, -3), ' ', substr($meter, 0, 3), "\n";
echo str_replace('kWh', 'kilowatt-hours', $reading), "\n";
echo implode(' | ', [$meter, $city]), "\n";
echo str_pad('42', 6, '0', STR_PAD_LEFT), ' ', str_repeat('-', 10), "\n";
echo ucwords('kestrel energy portal'), "\n";
[MTR-100231;Deventer;1542.7 kWh]
30
mtr-100231;deventer;1542.7 kwh
Deventer
100231 231 MTR
1542.7 kilowatt-hours
MTR-100231 | Deventer
000042 ----------
Kestrel Energy Portal
Function What it does
strlen($s) Length in bytes (see bytes and characters)
trim(), ltrim(), rtrim() Remove spaces, tabs and line breaks at both ends, the left or the right
strtolower(), strtoupper(), ucfirst(), ucwords() Change case (ASCII letters only)
substr($s, $start, $length) Part of a string; a negative start counts from the end
str_replace($search, $replace, $s) Replace every occurrence (case-sensitive; str_ireplace() ignores case)
explode($separator, $s), implode($glue, $array) Split a string into an array, and join an array into a string
str_pad(), str_repeat() Pad to a fixed width; repeat a string
str_contains(), str_starts_with(), str_ends_with() Test for a substring (PHP 8.0 and later)
strpos(), stripos() Position of the first match, or false

The line [$meter, $city, $reading] = explode(';', $clean); unpacks the three parts straight into variables; Arrays explains this destructuring. For splitting on a pattern rather than a fixed separator, PHP has preg_split() and the other preg functions.

Searching: strpos and str_contains

str_contains(), str_starts_with() and str_ends_with() answer yes-or-no questions and return true or false. strpos() returns the position of the first match, counted in bytes from 0, or false when there is none. var_dump() shows the type as well as the value, which makes the difference visible:

<?php
$subject = 'Outage in Deventer, ticket INC-31877';

var_dump(str_contains($subject, 'Deventer'));
var_dump(str_starts_with($subject, 'Outage'));
var_dump(str_ends_with($subject, '31877'));
var_dump(strpos($subject, 'INC'));
var_dump(strpos($subject, 'Zwolle'));
var_dump(stripos($subject, 'OUTAGE'));
bool(true)
bool(true)
bool(true)
int(27)
bool(false)
int(0)

That mix of return types (an integer or false) is a classic trap. If the text you look for is at the very start, strpos() returns 0, and 0 counts as false in an if:

<?php
$subject = 'Outage in Deventer';

// Wrong: strpos() returns 0 here, and 0 counts as false in an if
if (strpos($subject, 'Outage')) {
    echo "found (loose check)\n";
} else {
    echo "not found (loose check)\n";
}

// Right: compare with false strictly, or use str_contains()
if (strpos($subject, 'Outage') !== false) {
    echo "found (strict check)\n";
}
if (str_contains($subject, 'Outage')) {
    echo "found (str_contains)\n";
}
not found (loose check)
found (strict check)
found (str_contains)

Strings are values

Assigning a string to another variable copies it, and string functions return a new string instead of changing their argument. You can change a single byte through an index, $copy[0] = 'K', but that affects only that variable. Square brackets read one byte; a negative index counts from the end.

<?php
$code = 'ke-0042';
$copy = $code;          // strings are copied by value
$copy[0] = 'K';         // changing one byte of the copy

echo $code, "\n";
echo $copy, "\n";
echo $code[3], ' ', $code[-1], "\n";   // read one byte; negative counts from the end
echo strtoupper($code), "\n";          // functions return a new string
echo $code, "\n";
ke-0042
Ke-0042
0 2
KE-0042
ke-0042

This is different from Python and JavaScript, where strings are immutable and assigning to an index fails. In PHP it works, but because it works on bytes it is only safe for plain ASCII text.

Formatting with sprintf

sprintf() builds a string from a format string and a list of values. Each placeholder starts with % and ends with a letter that says how to format the next value: %s for a string, %d for a whole number, %f for a decimal number. Between them you can set a width, padding, alignment and the number of decimals. printf() does the same but prints the result instead of returning it.

<?php
$customer = 'Amara Okafor';
$kwh = 318.4;
$price = 0.27;

echo sprintf('%s used %d kWh', $customer, $kwh), "\n";
echo sprintf('Amount due: %.2f EUR', $kwh * $price), "\n";
echo sprintf('Meter %05d', 42), "\n";
echo sprintf('[%-10s][%10s]', 'left', 'right'), "\n";
echo sprintf("[%'.10s]", 'dots'), "\n";
echo sprintf('%1$s, %2$s. Again: %1$s', 'Deventer', 'Zwolle'), "\n";
echo sprintf('%x %b %e %%', 255, 5, 1542.7), "\n";
printf("%-12s %8.1f kWh\n", 'Deventer', 1542.66);
Amara Okafor used 318 kWh
Amount due: 85.97 EUR
Meter 00042
[left      ][     right]
[......dots]
Deventer, Zwolle. Again: Deventer
ff 101 1.542700e+3 %
Deventer       1542.7 kWh
Placeholder Meaning
%s, %d, %f String, integer, floating-point number
%.2f Two decimals (rounded)
%05d Width 5, padded with zeros
%10s, %-10s Width 10, right-aligned or (with -) left-aligned
%'.10s Width 10, padded with a custom character (here a dot)
%1$s, %2$s Use argument 1 or 2, so a value can be repeated or reordered
%x, %b, %e, %% Hexadecimal, binary, scientific notation, a literal percent sign

Note the first line of output: %d turned 318.4 into 318. It drops the decimals rather than rounding, so use %.0f when you want a rounded whole number. Placeholders such as %1$s contain a dollar sign, so write format strings in single quotes; in double quotes PHP would try to expand $s.

number_format

For amounts and large numbers, number_format() rounds to a number of decimals and adds a thousands separator. Its third and fourth arguments set the decimal point and the thousands separator, which makes Dutch or German notation easy:

<?php
$amount = 1234567.891;

echo number_format($amount), "\n";
echo number_format($amount, 2), "\n";
echo number_format($amount, 2, ',', '.'), "\n";   // Dutch style
echo number_format(0.125, 2), ' ', number_format(-0.4), "\n";
1,234,568
1,234,567.89
1.234.567,89
0.13 0

Comparing strings

=== compares two strings byte by byte and is case-sensitive. strcmp() and strcasecmp() (which ignores case) return 0 when the strings are equal, a negative number when the first sorts earlier and a positive number when it sorts later. strnatcmp() uses "natural order", in which item9 comes before item10.

<?php
var_dump('Deventer' === 'deventer');
var_dump(strcasecmp('Deventer', 'DEVENTER'));
var_dump(strcmp('apple', 'banana') < 0);
var_dump('10' == '1e1');      // both numeric: compared as numbers
var_dump('10' === '1e1');     // strict: different strings
var_dump('item10' < 'item9'); // byte by byte: '1' comes before '9'
var_dump(strnatcmp('item10', 'item9') > 0);
bool(false)
int(0)
bool(true)
bool(true)
bool(false)
bool(true)
bool(true)

Lines 4 and 5 are the reason to prefer ===. With ==, two strings that both look like numbers are compared as numbers, so '10' == '1e1' is true (1e1 is 10 in scientific notation). With === they are simply two different strings. Operators and Comparison has the full rules.

Bytes and characters

A PHP string is a sequence of bytes, not of characters. PHP does not know or record which character encoding the bytes are in. Almost all text today is UTF-8, in which the letters A to Z take one byte each but a letter such as ë takes two, and an emoji four. The classic string functions all count and cut bytes:

The string Zoë in UTF-8: three characters stored as four bytes. Z is byte 5a, o is byte 6f, and ë takes two bytes, c3 and ab. strlen counts the four bytes; a character count gives three. characters Z o ë bytes 5a 6f c3 ab strlen() = 4 bytes · mb_strlen() = 3 characters
The string 'Zoë' in UTF-8: three characters, four bytes. Byte functions such as strlen() and substr() see the bottom row.

The example below counts the characters in two ways that work on any installation: preg_match_all('/./u', …), where the u modifier makes the regular expression engine read UTF-8, and iconv_strlen() from the iconv extension. It also shows what goes wrong when a byte function cuts a character in half: substr($name, 0, 3) keeps only the first byte of ë, and the result is no longer valid UTF-8 (the test preg_match('//u', $cut) fails on invalid UTF-8). Browsers show such a broken byte as a replacement character or as garbage.

<?php
$name = 'Zoë';
echo strlen($name), " bytes\n";
echo preg_match_all('/./u', $name), " characters (preg, /u)\n";
echo iconv_strlen($name, 'UTF-8'), " characters (iconv)\n";
echo bin2hex($name), "\n";

$cut = substr($name, 0, 3);              // cuts ë in half
echo bin2hex($cut), "\n";
var_dump(preg_match('//u', $cut) === 1); // still valid UTF-8?
echo iconv_substr($name, 0, 3, 'UTF-8'), "\n";
echo strtoupper($name), "\n";            // only a-z change
4 bytes
3 characters (preg, /u)
3 characters (iconv)
5a6fc3ab
5a6fc3
bool(false)
Zoë
ZOë

The last line shows a second limit: strtoupper() only changes the ASCII letters a to z, so ë stays lowercase. The same goes for strtolower(), ucfirst() and ucwords().

The mb_ functions

The mbstring extension (multibyte string) adds a multibyte string function for most byte functions: mb_strlen(), mb_substr(), mb_strtoupper(), mb_strtolower(), mb_str_split(), mb_strpos() and more. They work on characters in a given encoding, UTF-8 by default. mbstring is a PHP extension that most hosting providers enable, but a minimal server may lack it; on Debian and Ubuntu it is the package php8.x-mbstring, and php -m lists what is loaded.

<?php
// Needs the mbstring extension (php8.2-mbstring on Debian)
$name = 'Zoë';

echo strlen($name), "\n";             // bytes
echo mb_strlen($name), "\n";          // characters
echo mb_substr($name, 0, 2), "\n";
echo mb_strtoupper($name), "\n";
echo mb_str_split($name)[2], "\n";
4
3
Zo
ZOË
ë