PHP Basics: Types, == vs ===, Strings and Arrays

PHP 8.5 basics from real runs: 0 == "a" after PHP 8, strict_types TypeErrors, strlen vs mb_strlen on CJK text, array key casting, the foreach & trap.

Series: PHP lecture 1/6 (beginner). For new developers who need to read or write PHP at work and want to start from the right habits. By the end you will be able to run a script, predict how PHP compares and converts values, handle Japanese and Korean text without breaking it, and avoid the two array traps every PHP beginner hits.

This lecture is for people who just got handed a PHP code base, or who learned PHP from a 2015 tutorial and wonder why the answers on the internet disagree with each other. Most of that confusion comes from one fact: PHP 8 quietly changed how == works, how strict types behave, and which mistakes produce a warning instead of silence. Every output below was produced by actually running the code with PHP 8.5.6 CLI on an Apple Silicon Mac (M1 Pro); file paths in error messages are shortened to ~/demo/.

Your first PHP script in 5 minutes

If php -v prints a version, you are ready. (If not: on macOS most people use Homebrew, on Windows the zip from windows.php.net, on Ubuntu apt install php-cli php-mbstring. We did not reinstall anything for this article.) Create hello.php:

<?php
echo "Hello, PHP " . PHP_VERSION . "\n";
$ php hello.php
Hello, PHP 8.5.6

Three things to notice:

  • A PHP file starts with the opening tag <?php. Everything before it is sent out as plain text. In a file that contains only PHP, leave out the closing ?> tag, so no stray newline is ever printed after it.
  • echo prints. The dot . joins (concatenates) strings. + is only for numbers.
  • PHP_VERSION is a built-in constant (a name whose value never changes).

php -r 'echo 1 + 1;' runs one line without a file. It is handy for quick checks, and we use it below.

Variables and types

A variable starts with $. You do not declare its type; the value carries the type. get_debug_type() (PHP 8.0+) prints it in the same words that error messages use:

<?php
$n = 42; $f = 3.14; $s = "42"; $b = true; $x = null; $a = [1, 2];
foreach ([$n, $f, $s, $b, $x, $a] as $v) {
    echo get_debug_type($v), " ";
}
echo "\n";
var_dump("10" + 5, "1.5" + 1, 7 / 2, intdiv(7, 2), 0.1 + 0.2 == 0.3);
int float string bool null array 
int(15)
float(2.5)
float(3.5)
int(3)
bool(false)

var_dump() shows type and value together, and is the tool you will use most while learning. Two things here surprise people from other languages. First, "10" + 5 is 15: arithmetic on a numeric string converts it to a number. Second, / always gives a float when the division is not exact; use intdiv() for integer division. 0.1 + 0.2 == 0.3 is false for the same binary floating-point reason as in every other language.

== vs ===, and what PHP 8 changed

== is loose comparison: PHP may convert one side before comparing. === is strict comparison: same type and same value, no conversion. Here is what PHP 8.5.6 printed for a list of classic cases:

<?php
$cases = [
  ['0 == "a"', 0 == "a"],        ['"1" == "01"', "1" == "01"],
  ['"10" == "1e1"', "10" == "1e1"], ['100 == "1e2"', 100 == "1e2"],
  ['null == false', null == false], ['[] == false', [] == false],
  ['"0" == false', "0" == false],  ['"" == null', "" == null],
  ['1 === "1"', 1 === "1"],        ['42 == "42abc"', 42 == "42abc"],
  ['42 == " 42"', 42 == " 42"],    ['42 == "42 "', 42 == "42 "],
];
foreach ($cases as [$label, $r]) printf("%-16s %s\n", $label, var_export($r, true));
0 == "a"         false
"1" == "01"      true
"10" == "1e1"    true
100 == "1e2"     true
null == false    true
[] == false      true
"0" == false     true
"" == null       true
1 === "1"        false
42 == "42abc"    false
42 == " 42"      true
42 == "42 "      true

The first line is the famous PHP 8 change. Before PHP 8.0, 0 == "a" converted "a" to the number 0 and returned true, which made in_array("abc", [0]) true and broke a lot of login checks. Since PHP 8.0, if the string is not numeric, PHP turns the number into a string and compares two strings, so the answer is false. (We only ran 8.5 here; the PHP 7 behaviour is from the "Saner string to number comparisons" RFC.)

But look at the rest of the table. Two strings that look like numbers are still compared as numbers: "1" == "01" and "10" == "1e1" are true. If those are product codes or passwords, that is a bug. null, false, "", "0" and [] are all loosely equal to false.

Flow diagram. Start with a == b where one side is an int or float and the other is a string. Question: is the string numeric, like "01", "1e1" or " 42"? If yes, both sides are compared as numbers, so "1" == "01" and 100 == "1e2" are true. If no, the number is converted to a string and two strings are compared, so 0 == "a" and 42 == "42abc" are false; on PHP 7 these were true. The safe default is ===, which never converts.
How PHP 8 evaluates a == b between a number and a string. Numeric strings still compare as numbers; a non-numeric string turns the comparison into a string comparison, so 0 == "a" is false.
You are comparing Use
IDs, codes, tokens, anything typed by users === (and hash_equals() for secrets)
Search in an array in_array($x, $arr, true), array_search($x, $arr, true)
"Is it missing?" $x === null, or isset() / ?? (lecture 2)
Numbers that may arrive as strings from a form convert first ((int), filter_var), then ===

Make === your default and use == only when you can explain the conversion.

declare(strict_types=1) and the real TypeError

You can declare the types a function accepts and returns. By default PHP runs in coercive mode: it tries to convert a wrong-but-convertible argument.

<?php
function addTax(int $price): int {
    return (int) round($price * 1.1);
}
var_dump(addTax("1000"));
var_dump(addTax(1000.0));
var_dump(addTax("1000 yen"));
int(1100)
int(1100)

Fatal error: Uncaught TypeError: addTax(): Argument #1 ($price) must be of type int, string given, called in ~/demo/loose.php on line 7 and defined in ~/demo/loose.php:2
Stack trace:
#0 ~/demo/loose.php(7): addTax('1000 yen')
#1 {main}
  thrown in ~/demo/loose.php on line 2

"1000" was silently converted to 1000. Add declare(strict_types=1); as the first statement of the file and the same call is refused:

<?php
declare(strict_types=1);

function addTax(int $price): int {
    return (int) round($price * 1.1);
}
var_dump(addTax(1000));
var_dump(addTax("1000"));
int(1100)

Fatal error: Uncaught TypeError: addTax(): Argument #1 ($price) must be of type int, string given, called in ~/demo/strict.php on line 8 and defined in ~/demo/strict.php:4

How to read it: the error type is TypeError, "Argument #1 ($price)" tells you which parameter, and "called in … line 8" is where the bad value came from. Fix the caller, not the function.

Two details beginners miss:

  • strict_types applies to calls made from the file that declares it, not to the function's file. A strict library called from a non-strict file still converts.
  • Even strict mode allows int → float (g(5) with float $x gave float(5)). In coercive mode, passing 1.5 to an int parameter now gives Deprecated: Implicit conversion from float 1.5 to int loses precision and the value int(1). That deprecation was added in PHP 8.1.

On the terminal you may see each error twice, once prefixed with PHP. That is the CLI writing to the log (stderr) and to the screen. We trimmed the copies.

Strings: quotes, interpolation, heredoc

<?php
$name = "Mina";
$n = 3;
echo 'Hi $name\n', "\n";
echo "Hi $name, you have {$n} new messages\n";
$user = ['name' => 'Mina'];
echo "Hi {$user['name']}\n";
echo <<<TXT
    Dear $name,
      You have {$n} items.
    TXT;
echo "\n";
echo <<<'RAW'
    No $interpolation here\n
    RAW;
Hi $name\n
Hi Mina, you have 3 new messages
Hi Mina
Dear Mina,
  You have 3 items.
No $interpolation here\n
  • Single quotes: almost nothing is special. $name and \n stay as typed.
  • Double quotes: variables are interpolated (replaced by their value) and escapes like \n work. Wrap anything more than a plain variable in {…}.
  • Heredoc <<<TXT is a multi-line double-quoted string; nowdoc <<<'RAW' is the single-quoted version. Since PHP 7.3 the closing marker may be indented, and that indentation is removed from every line, which is why "Dear" starts at column 0.

Japanese and Korean text: strlen vs mb_strlen

PHP strings are byte strings. strlen() counts bytes. For text that people read, you need the mb_ (multibyte) functions from the mbstring extension:

<?php
foreach (["hello", "こんにちは", "안녕하세요", "👍🏽"] as $s) {
    printf("%-12s strlen=%2d  mb_strlen=%d\n", $s, strlen($s), mb_strlen($s));
}
$s = "こんにちは";
var_dump(substr($s, 0, 4), mb_substr($s, 0, 2));
var_dump(strrev("안녕"));
var_dump(mb_strwidth("こんにちは"), grapheme_strlen("👍🏽"));
hello        strlen= 5  mb_strlen=5
こんにちは strlen=15  mb_strlen=5
안녕하세요 strlen=15  mb_strlen=5
👍🏽     strlen= 8  mb_strlen=2
string(4) "こ�"
string(6) "こん"
string(6) "��눕�"
int(10)
int(1)

Even the output proves the point: printf("%-12s") pads by bytes, so the CJK rows are misaligned. Each kana or Hangul syllable is 3 bytes in UTF-8. substr($s, 0, 4) cut the second character in half and produced an invalid byte (�), and strrev reversed bytes, not characters. A thumbs-up with a skin tone is two code points (mb_strlen = 2) but one visible character; only grapheme_strlen() from the intl extension says 1. mb_strwidth counts full-width characters as 2, useful for fixed-width layouts.

The rule: a length limit on a user name, a "first 20 characters" preview, an uppercase conversion of non-ASCII text, all need mb_strlen, mb_substr, mb_strtoupper. Newer helpers exist too: mb_str_pad (8.3), mb_trim and mb_ucfirst (8.4). On 8.5, trim(" 全角スペース ") left the full-width spaces in place, while mb_trim removed them.

Arrays are ordered maps

A PHP array is not a list in the C or Java sense. It is an ordered map: keys (int or string) mapped to values, keeping insertion order. The trap is that PHP converts some keys for you:

<?php
$a = [];
$a["1"]   = "string one";
$a[1]     = "int one";
$a[true]  = "bool true";
$a["01"]  = "string 01";
$a[1.7]   = "float 1.7";
$a[null]  = "null";
$a["1.5"] = "string 1.5";
var_dump($a);
Deprecated: Implicit conversion from float 1.7 to int loses precision in ~/demo/arr.php on line 7

Deprecated: Using null as an array offset is deprecated, use an empty string instead in ~/demo/arr.php on line 8
array(4) {
  [1]=>
  string(9) "float 1.7"
  ["01"]=>
  string(9) "string 01"
  [""]=>
  string(4) "null"
  ["1.5"]=>
  string(10) "string 1.5"
}

Seven assignments, four keys. "1", 1, true and 1.7 all became the integer key 1, each one overwriting the previous value. "01" and "1.5" stayed strings because they are not in canonical integer form. null became "", and PHP 8.5 now warns about it.

Where this bites in real code: you key an array by user IDs from a database ("100", "200"). They become integer keys, and array_merge() renumbers integer keys:

<?php
$byId = ["100" => "Alice", "200" => "Bob"];
print_r(array_merge($byId, ["300" => "Chen"]));
print_r($byId + ["300" => "Chen"]);
Array
(
    [0] => Alice
    [1] => Bob
    [2] => Chen
)
Array
(
    [100] => Alice
    [200] => Bob
    [300] => Chen
)

Use + (or array_replace) when keys matter. Also, unset($list[1]) on ["a", "b", "c"] leaves keys 0 and 2. array_is_list() (8.1+) returned false, and the next $list[] = "d" went to key 3. Call array_values() to renumber.

Common mistake: foreach by reference duplicates the last element

You want to double every price in place, and then print them:

<?php
$prices = [100, 200, 300];
foreach ($prices as &$p) {
    $p = $p * 2;
}
foreach ($prices as $p) {
    echo $p, " | ";
}
echo "\n";
print_r($prices);
200 | 400 | 400 | 
Array
(
    [0] => 200
    [1] => 400
    [2] => 400
)

What actually happens: &$p makes $p a reference (a second name for the same storage) to each element in turn. When the first loop ends, $p is still a reference to $prices[2]. The second loop assigns each element to $p, which means writing into $prices[2]: first 200, then 400, then, at the last step, $prices[2] is copied onto itself. 600 is gone, with no warning.

Flow diagram. The first loop foreach ($prices as &$p) doubles [100, 200, 300] to [200, 400, 600]. When it ends, $p is still bound to $prices[2]. The second loop foreach ($prices as $p) assigns 200, then 400, then the current value of $prices[2] into that same slot, so the array becomes [200, 400, 400]. The fix branch calls unset($p) after the first loop, and the second loop then prints 200 400 600.
The foreach-by-reference trap. After the first loop $p is still a reference to the last element, so the second loop writes every value into $prices[2]. unset($p) breaks the link.

Fix: unset($p); immediately after a reference loop. Better, do not use a reference at all:

<?php
$prices = [100, 200, 300];
$doubled = array_map(fn($x) => $x * 2, $prices);          // new array
foreach ($prices as $k => $v) { $prices[$k] = $v * 2; }   // in place, by key
echo implode(" ", $doubled), " / ", implode(" ", $prices), "\n";
200 400 600 / 200 400 600

Warnings for undefined variables and keys

PHP 8 turned most "you used something that does not exist" mistakes from a quiet Notice into a Warning. The script keeps running, so read the warnings:

<?php
echo $count + 1, "\n";
$user = ["name" => "Mina"];
echo $user["email"], "|\n";
$list = [10, 20];
echo $list[5], "|\n";
$s = "abc";
echo $s[10], "|\n";
$total = null;
echo $total["sum"], "|\n";
echo $user["email"] ?? "(no email)", "\n";
Warning: Undefined variable $count in ~/demo/undef.php on line 2
1

Warning: Undefined array key "email" in ~/demo/undef.php on line 4
|

Warning: Undefined array key 5 in ~/demo/undef.php on line 6
|

Warning: Uninitialized string offset 10 in ~/demo/undef.php on line 8
|

Warning: Trying to access array offset on null in ~/demo/undef.php on line 10
|
(no email)

The undefined $count was treated as null and null + 1 printed 1. That is how a typo in a variable name becomes a wrong total instead of a crash. The last line shows the fix for keys that may legitimately be missing: the null coalescing operator ?? returns the right side when the left side is missing or null, with no warning. Lecture 2 uses it for form input.

A measurement: building strings and searching arrays

Small timings, so you know which habits matter. Setup: 100,000 random numeric strings (mt_srand(42)), each case run 7 times, median reported, PHP 8.5.6 CLI, Apple Silicon Mac (M1 Pro). OPcache and JIT are off by default in the CLI, which is what we measured. A run with JIT turned on changed nothing meaningful (in_array 47.9 ms instead of 53–54 ms; all others within 0.1 ms).

<?php
mt_srand(42);
$words = [];
for ($i = 0; $i < 100_000; $i++) $words[] = (string) mt_rand(1, 999_999);

function bench(callable $fn, int $runs = 7): float {
    $t = [];
    for ($r = 0; $r < $runs; $r++) {
        $s = hrtime(true); $fn(); $t[] = (hrtime(true) - $s) / 1e6;
    }
    sort($t);
    return $t[intdiv($runs, 2)];
}
printf("concat: %.3f ms\n", bench(function () use ($words) {
    $out = ''; foreach ($words as $w) { $out .= $w . ','; } return $out; }));
printf("implode: %.3f ms\n", bench(fn() => implode(',', $words)));
// the full file also times foreach-sum vs array_sum and in_array vs isset
Task (100,000 items) Median, run 1 Median, run 2
.= in a loop 2.872 ms 2.856 ms
implode() 0.784 ms 0.775 ms
sum with foreach 2.354 ms 2.363 ms
array_sum() 1.811 ms 1.811 ms
1,000 lookups, in_array on 10,000 items 53.29 ms 54.10 ms
1,000 lookups, isset on flipped keys 0.021 ms 0.022 ms
Bar chart of median times for 100,000 items. Concatenating with .= in a loop took 2.87 ms, implode took 0.78 ms, summing with foreach took 2.35 ms, and array_sum took 1.81 ms.
Median of 7 runs over 100,000 numeric strings (mt_srand(42)), PHP 8.5.6 CLI, OPcache/JIT off, Apple M1 Pro. Not shown: 1,000 lookups took 53.3 ms with in_array and 0.021 ms with isset on flipped keys.

implode() was about 3.7× faster than .= and array_sum() about 1.3× faster than a loop. Both are real, but you only notice them at hundreds of thousands of items. The last pair is the one that matters: in_array scans the whole list each time, while isset($set[$key]) on an array built with array_flip() is a hash lookup. That was about 2,500× faster here, and the gap grows with the list. (Because of the key casting above, flipped numeric strings become int keys. That is fine for isset, but remember it if you read the keys back.)

Honest limits / version notes

  • Everything was run on PHP 8.5.6 CLI (Homebrew build, mbstring and intl loaded) on macOS on an Apple M1 Pro. We did not run PHP 7 or older 8.x versions. The PHP 7 result for 0 == "a" and the "added in" versions below come from the PHP manual and RFCs, not from our runs.
  • Versions: string-to-number comparison change, get_debug_type(), Warnings for undefined variables/keys (8.0); float-to-int Deprecated, array_is_list() (8.1); mb_str_pad (8.3); mb_trim, mb_ucfirst (8.4); "Using null as an array offset is deprecated" (8.5). strict_types dates back to 7.0.
  • Timings are medians of 7 runs on one machine and will differ on a shared host. Our CLI runs had OPcache off, which is the CLI default; lecture 6 measures OPcache and JIT properly.
  • grapheme_strlen needs the intl extension. Some cheap hosts do not enable it, so check php -m.

Exercises

1. Without running it, predict each result, then check with php -r: "abc" == 0, "1" == "01", "10" == "1e1", null == false, "abc" == "ABC".

Answer

false, true, true, true, false (PHP 8.5.6). "abc" == 0 is false since PHP 8. The two numeric strings compare as numbers. null == false is a loose bool comparison. String comparison is case-sensitive. With ===, all five are false.

2. A sign-up form must allow names of up to 10 characters. The code is strlen($name) <= 10. Why does "たなかはなこさん" (8 characters) get rejected, and what is the fix?

Answer

strlen("たなかはなこさん") is 24 bytes (8 × 3), so the check fails. Use mb_strlen($name) <= 10. We ran a fits() function using mb_strlen and it returned true for this name.

3. How many elements does ["1" => "a", 1 => "b", "1.0" => "c", true => "d"] have, and what are the keys?

Answer

Two. "1", 1 and true all become the int key 1 (final value "d"); "1.0" is not a canonical integer string, so it stays a string key. print_r showed [1] => d and [1.0] => c.

Next

FAQ

Is 0 == "a" true or false in PHP 8?

False. Since PHP 8.0, comparing a number with a non-numeric string converts the number to a string and compares two strings, so 0 == "a" is false. On PHP 7 the string was cast to 0 and the result was true. Numeric strings still compare as numbers: "1" == "01" and "10" == "1e1" are both true on PHP 8.5, which is why === is the safe default.

Why does strlen() return 15 for a 5-character Japanese or Korean word?

strlen() counts bytes, not characters. In UTF-8 each kana, kanji or Hangul syllable takes 3 bytes, so こんにちは and 안녕하세요 both gave strlen=15 and mb_strlen=5. Use mb_strlen, mb_substr and the other mb_ functions for user-visible text.

Why does my foreach loop duplicate the last element of the array?

After foreach ($a as &$v) the variable $v is still a reference to the last element. A second foreach ($a as $v) then writes each value into that last slot, so [200, 400, 600] became [200, 400, 400]. Call unset($v) right after the reference loop, or avoid the reference with array_map or $a[$k] = ...

  • #php
  • #beginner
  • #types
  • #strict-types
  • #mbstring
  • #arrays

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑