PHP in a nutshell: Data types and casting
What this part is about
In the previous part we enabled declare(strict_types=1) and I said it’s the most important line in the file.
Now you’ll see why — because underneath, PHP is a language that really wants to turn one thing into another,
and it does so according to rules you can’t work out from intuition.
This part is a catalogue of those rules. You don’t have to memorise all of it — I’ll point out the two things you
genuinely need to keep in your head: the list of falsy values and the rules of comparison with ==. The rest
is reference material you can come back to whenever you need it.
How PHP treats types
PHP is dynamically typed — the type information is carried by the value assigned to a variable, not by the variable itself. The same variable can hold a number, then a string, then an array, and the interpreter won’t protest in any way.
In practice this means that you can’t give a variable a type. Type declarations exist in PHP in only three places:
function f(int $x): string { } // ✅ parameter and return type
class A{ public int $count = 0; // ✅ class property (since PHP 7.4)}
int $x = 2; // ❌ syntax error — this construct simply doesn't exist$x = 2; // ✅ the only possible formThe language itself doesn’t make up for this gap — there’s no syntax that would enforce the type of an ordinary
variable. What does make up for it are static analysis tools: PHPStan and Psalm read the code without running it
and can catch that a variable ended up holding something other than what the rest of the program implies. They also
understand the @var annotation, which to PHP itself is just a comment, but to them is a binding declaration:
/** @var int $count */$count = $repository->count();In practice these tools are what close the gap, and in any serious PHP project they’re the standard today — we’ll come back to them in the series on tooling.
PHP is also weakly typed — by default it converts types on the fly so that an operation can go through. You turn
that off with declare(strict_types=1), but only where a value is passed somewhere: when passing an argument to a
function, when returning a result from it, and when assigning to a typed class property. In ordinary expressions this
conversion — called type juggling — keeps working, regardless of strict mode.
<?phpdeclare(strict_types=1);
function double(int $n): int{ return $n * 2;}
double('5'); // TypeError — enforced HERE, because the value goes into a function
$x = '5' + 3; // int(8) — NOT enforced here, it's an ordinary expressionvar_dump('5' == 5); // true — comparison works as always, tooThis distinction matters, because it’s easy to assume that one line “fixes typing in PHP”. It guards those three places. Everything else remains your responsibility.
Type categories
| Category | Types |
|---|---|
| Scalar | bool, int, float, string |
| Compound | array, object, callable, iterable |
| Special | null, resource |
resource is a relic — a handle to something PHP itself manages outside your code: an open file, a database
connection, a stream. New PHP extensions consistently replace it with regular objects, so you’ll mostly run into it
in older APIs.
Checking the type
var_dump($x); // type + value — best for debugging
gettype($x); // 'integer', 'double', 'boolean' — old names, DON'T useget_debug_type($x); // 'int', 'float', 'bool', 'App\User' — PHP 8, use THIS
is_int($x); is_float($x); is_string($x); is_bool($x);is_array($x); is_null($x); is_object($x);is_callable($x); is_iterable($x); is_numeric($x);This is what var_dump() prints for a few typical values — notice that for a string it also gives its length, and
for an array the number of elements:
var_dump(42); // int(42)var_dump(4.2); // float(4.2)var_dump('hi'); // string(2) "hi"var_dump(true); // bool(true)var_dump(null); // NULLvar_dump([1, 'a']); // array(2) { [0]=> int(1) [1]=> string(1) "a" }The difference between gettype() and get_debug_type() isn’t cosmetic. gettype() returns 'double' for a float
and 'integer' for an int — names you can’t put in a type declaration or compare with a class name. For an object it
simply returns 'object'. get_debug_type() returns exactly what you would write in code, including the fully
qualified class name. For error messages and logs it’s the only sensible choice.
is_numeric()checks whether a string can be treated as a number:is_numeric('1e3')istrue, and so isis_numeric(' 12'), because a leading space is allowed.is_numeric('12 '), on the other hand, with a trailing space, only returnstruesince PHP 8.0.
Casting
Explicit casting
(int) '12abc'; // 12 — parses the leading numeric part and stops(int) 'abc'; // 0(int) ''; // 0(float) '1.5e3'; // 1500.0(bool) '0'; // false ← surprising(bool) '0.0'; // true ← even more so(string) true; // '1'(string) false; // '' ← empty string, NOT '0'(array) $obj; // public properties become keysTwo things from this list come up most often. First, (int) on a string is not validation — '12abc' turns into
12 without a hint of a warning. If you’re checking input data, use filter_var() or ctype_digit(), never a bare
cast.
Second, (string) false gives an empty string, not '0'. This regularly breaks string concatenation and
database writes.
Function equivalents
intval('0x1A', 16); // 26 — the second argument is the basefloatval('1.5abc'); // 1.5strval(3.0); // '3'boolval('0'); // false
settype($x, 'integer'); // changes the type of the variable $x itself, returns boolintval() with a base is the only thing (int) can’t do. Otherwise, prefer the parenthesised syntax, i.e.
(int) '12abc' instead of intval('12abc') — it’s more readable and faster.
Casting to an object
(object) ['a' => 1]; // stdClass with a property ->a(object) [1, 2]; // numeric keys become properties "0" and "1"$o->{0}; // ← brace syntax required; $o->0 is a parse error(object) 'hi'; // stdClass { scalar: 'hi' } — same for int/float/bool(object) null; // empty stdClassIn practice you’ll run into this with JSON, because json_decode() returns stdClass by default, not an array:
json_decode($json); // stdClass — BY DEFAULTjson_decode($json, true); // associative arrayYou almost always want
trueas the second argument — but not because an array is “safer”. Neither an array norstdClasshas a declared structure: in both cases your IDE won’t suggest field names, and reaching for a non-existent one ends with a warning and anullvalue. The array’s advantage is purely practical —??, destructuring and the whole family ofarray_*functions work on it, and nested data doesn’t end up mixing arrays with objects. The real solution is a class of your own, and we’ll come back to that in part ten.
Falsy values
Falsy values are the ones PHP considers false wherever it expects a condition. Everything else is truthy.
This list is short and worth knowing by heart, because it determines the behaviour of every if, while, empty()
and ?: operator in your code.
false0-00.0-0.0'''0'[]nullEverything else is truthy. In particular, these are truthy:
'0.0' // a string that looks like zero' ' // a space'false' // the string 'false''[]' // a string that looks like an empty array-1 // any non-zero number, negative ones included0.00001[0] // an array with a single falsy element — the array itself isn't emptynew stdClass() // every object, even one without propertiesThe most common slip-up is '0'. If a form sends a page number, a quantity or a postcode, the value '0' will pass
through if ($value) as if there were no data:
$qty = '0';
if ($qty) { } // ❌ won't enter — '0' is falsyif ($qty !== '') { } // ✅ an explicit check for what you actually meanif (isset($qty)) { } // ✅ when you're asking about existence, not valueComparisons: == versus ===
I promised this at the end of the previous part, so let’s start with the most important change in PHP 8.
=== compares type and value. == juggles types first, then compares values. And it’s exactly those juggling
rules that were rewritten in PHP 8.
// PHP 7 PHP 80 == 'foobar'; // true false0 == ''; // true false'abc' == 0; // true falseThe old rule was: when comparing a number with a string, the string is cast to a number. Since 'abc' cast to
int gives 0, you ended up with 0 == 'abc'. This was one of the most common sources of security bugs in PHP: a
check like $passwordFromForm == 0 came out true for any text.
The new rule is the reverse and far more sensible: if the string is not numeric, the number is cast to a string and the comparison is done as text. If the string is numeric, both sides are compared as numbers:
'1' == '01'; // true — both strings numeric, compared as numbers'10' == '1e1'; // true — exponent notation is a number, too100 == '1e2'; // true'abc' == 0; // false — 'abc' isn't numeric, so 0 becomes '0'null == false; // true[] == false; // true| Expression | Result | Why |
|---|---|---|
0 == 'abc' |
false |
non-numeric string → '0' == 'abc' |
0 == '0' |
true |
numeric string → numeric comparison |
'1' == '01' |
true |
both numeric → 1 == 1 |
'abc' == 'ABC' |
false |
== on strings doesn’t ignore case |
null == 0 |
true |
both falsy |
null === 0 |
false |
different types |
Rule of thumb: use
===until you have a specific reason to do otherwise. This rule will save you more time than memorising the entire table above.
The <=> operator — the spaceship operator, because that’s what it looks like — compares two values and tells you
which one is greater: it returns -1 when the left one is smaller, 0 when they’re equal, and 1 when the left one
is greater. That’s exactly the answer sorting functions expect when you give them your own comparison rule:
1 <=> 2; // -1[1, 2, 3] <=> [1, 2, 3]; // 0
usort($users, fn($a, $b) => $a->age <=> $b->age);We’ll go through the full operator table and precedence pitfalls in part four. Here the only point was what == does
with types.
Integers
PHP_INT_MAX; // 9223372036854775807 — the largest int on a machine with a 64-bit CPUPHP_INT_MIN; // -9223372036854775808 — and the smallestPHP_INT_SIZE; // 8 — how many bytes a single int takes (8 bytes is exactly 64 bits)Overflow silently changes the type
In most languages, an int that goes past its range wraps around to the other end — in Java or C#, adding one to the largest int suddenly gives you a negative number:
// JavaInteger.MAX_VALUE // 2147483647Integer.MAX_VALUE + 1 // -2147483648 ← wrapped around to the other end of the rangePHP doesn’t do that, and it doesn’t throw an exception either. The result simply changes type to float:
var_dump(PHP_INT_MAX); // int(9223372036854775807)var_dump(PHP_INT_MAX + 1); // float(9.2233720368548E+18) ← this is no longer an intThere’s no warning. If further down the code you assume an int, you’ll get a TypeError in a completely different
place from the source of the problem.
Literal notation
0b1010; // binary = 100o17; // octal = 15 (syntax from PHP 8.1)017; // octal = 15 (old syntax — easy to overlook)0x1A; // hexadecimal = 261_000_000; // digit separator (7.4+) = 1000000The old octal notation is a real trap: 017 is not seventeen. If you’re pasting file permissions or codes, a
leading zero changes what the number means.
Underscores are a parser feature, not a data format. Inside a string they mean nothing:
(int) '2_00'gives2, not200— the cast stops at the first non-digit character.
Floating-point numbers
PHP_FLOAT_MAX; // 1.7976931348623157E+308 — the largest number a float can holdPHP_FLOAT_MIN; // 2.2250738585072E-308 — the smallest POSITIVE one, not the smallest overallPHP_FLOAT_DIG; // 15 — how many significant digits are still reliablePHP_FLOAT_EPSILON; // 2.220446049250313E-16 — the smallest difference a float can tell apart at allNotice PHP_FLOAT_MIN — unlike PHP_INT_MIN, it isn’t the smallest possible number, only the smallest
positive one. The smallest number overall is -PHP_FLOAT_MAX.
Never compare floats with ==
var_dump(0.1 + 0.2 == 0.3); // falseThis isn’t a flaw in PHP — it’s how IEEE 754 works, the floating-point standard used by practically every language.
Fractions like 0.1 can’t be represented exactly in binary, so underneath sits a number that’s slightly different
from the one you typed. The easiest way to see it is to print the result at full precision:
echo 0.1 + 0.2; // 0.3 ← what it LOOKS likevar_dump(0.1 + 0.2); // float(0.30000000000000004)printf('%.20f', 0.1 + 0.2); // 0.30000000000000004441 ← what it REALLY isecho rounds to fourteen significant digits when printing, so it shows a nice 0.3 — and that’s exactly why this
bug is so hard to track down. The comparison sees the real value, not the printed one. The correct check is therefore
whether the difference falls within the margin of error:
var_dump(abs((0.1 + 0.2) - 0.3) < PHP_FLOAT_EPSILON); // trueThe margin of error here is PHP_FLOAT_EPSILON from the constants above: a difference smaller than that means that,
as far as the float type is concerned, it’s simply the same number.
The same mechanism breaks rounding down:
floor((0.1 + 0.7) * 10); // 7, not 8 — the classic binary representation trapMoney
Never store amounts of money as float. You have two sensible options:
// 1. Integers in the smallest unit (cents) — the simplest and fastest$price = 1999; // $19.99
// 2. The bcmath / gmp extension, when you need decimal arithmeticbcadd('0.1', '0.2', 2); // '0.30'In part ten we’ll build a Money value object out of this, which keeps track of the unit and currency for you.
Division, remainder and rounding
intdiv(10, 3); // 3 — integer division: truncates the remainder, doesn't roundintdiv(10, 0); // DivisionByZeroError — division by zero throws an exception
10 % 3; // 1 — remainder, works ONLY on integers10.5 % 3; // 1 — 10.5 is silently truncated to 10 first, then the remainder-10 % 3; // -1 ← the sign of the result comes from the LEFT side, not the rightfmod(10.5, 3); // 1.5 — remainder for floats, keeps the fractional part
fdiv(1, 0); // INF — the only division by zero that does NOT throwfdiv(-1, 0); // -INFfdiv(0, 0); // NAN — because 0/0 has no meaningful result
floor(7.9); // 7.0 — rounds down, but returns a FLOAT, not an intceil(7.1); // 8.0 — rounds up, also a FLOATround(2.5); // 3.0 — rounds halves up, i.e. away from zeroround(-2.5); // -3.0 — which is why minus two and a half goes to minus threeround(2.345, 2); // 2.35 — the second argument is the number of decimal placesTwo things to remember: floor() and ceil() return a float, so under strict_types=1 you can’t pass them
directly where an int is required — you need (int) floor($x) or intdiv(). And % always works on ints, so
10.5 % 3 will silently turn 10.5 into 10.
Change in PHP 8:
1 / 0now throws aDivisionByZeroErrorinstead of emitting a warning and returningINF. If you deliberately wantINF, usefdiv().
NAN; // constant: "not a number" — the result of an operation with no meaningful valueINF; // constant: infinity
log(-1); // NAN — the logarithm of a negative number doesn't existsqrt(-1); // NAN — neither does the square root of onePHP_FLOAT_MAX * 2; // INF — the result no longer fits in a float
is_nan(sqrt(-1)); // true — the only way to detect NAN (see below)is_infinite(PHP_FLOAT_MAX * 2); // true — whether it's infinityis_finite(1.5); // true — an ordinary number: neither NAN nor infinityNAN has one more surprising property: it isn’t equal to itself. NAN == NAN is false, so use is_nan() to
check for it.
Strings
Single quotes versus double quotes
This is the first thing that sets PHP apart from most languages — the kind of quote changes the behaviour.
$name = 'Paweł';
echo 'Hello $name\n'; // literally: Hello $name\necho "Hello $name\n"; // Hello Paweł + a newline characterSingle quotes treat text literally: $name stays in the output as $name, and \n as a backslash followed by the
letter n. They understand only two backslash sequences: \' inserts a single quote in the middle of the text, and
\\ a backslash itself.
Double quotes do two more things. First, they interpolate, meaning they replace a variable name with its value.
Second, they treat the backslash as the start of a character you can’t type directly on the keyboard: \n is a
newline, \t a tab, \u{1F600} an emoji. These are called escape sequences, and they only work inside double quotes.
More complex expressions need braces:
echo "Value: {$obj->prop['key']}"; // braces requiredecho "Array: $arr[key]"; // key WITHOUT quotes — simple syntax onlyecho "Array: {$arr['key']}"; // brace syntax — with quotesA practical rule: use single quotes by default, and double quotes when you’re actually interpolating. It’s not about performance (the difference is negligible) — it’s that a single quote clearly says “there’s nothing to substitute here”.
Heredoc and nowdoc
Heredoc behaves like double quotes — it interpolates:
$html = <<<HTML <div class="card"> <h1>$title</h1> </div> HTML;Since PHP 7.3 the closing marker can be indented, and that indentation is removed from every line of the content. The marker can’t be indented further than the content — that’s a parse error.
Nowdoc behaves like single quotes — it doesn’t interpolate anything:
$sql = <<<'SQL' SELECT * FROM users WHERE name = '$name' SQL;Nowdoc is the right choice wherever the text contains dollar signs you don’t want substituted: SQL queries, templates, JavaScript snippets, regular expressions.
The most commonly used functions
| Function | What it does | Example → result |
|---|---|---|
strlen($s) |
Length in bytes | strlen('żółw') → 7 |
mb_strlen($s) |
Length in characters | mb_strlen('żółw') → 4 |
str_contains($h, $n) |
Whether it contains a substring (PHP 8) | str_contains('Hello', 'ell') → true |
str_starts_with() / str_ends_with() |
Prefix / suffix test (PHP 8) | str_starts_with('Hello', 'He') → true |
strpos($h, $n) |
Position of the first occurrence, or false |
strpos('Hello', 'l') → 2 |
substr($s, $start, $len) |
A slice of the string | substr('Hello', 1, 3) → 'ell' |
str_replace($find, $rep, $subj) |
Replaces every occurrence | str_replace('l', 'L', 'Hello') → 'HeLLo' |
sprintf($fmt, ...) |
Builds a formatted string | sprintf('%05.2f', 3.5) → '03.50' |
number_format($n, $dec) |
Formats a number for display | number_format(1234.5, 2) → '1,234.50' |
trim() / ltrim() / rtrim() |
Strips whitespace or the given characters | trim(' hi ') → 'hi' |
explode($delim, $s, $limit) |
Splits a string into an array | explode(',', 'a,b,c') → ['a','b','c'] |
implode($glue, $arr) |
Joins an array into a string | implode('-', ['a','b']) → 'a-b' |
str_pad($s, $len, $pad, $type) |
Pads to a fixed width | str_pad('7', 3, '0', STR_PAD_LEFT) → '007' |
ucfirst() / ucwords() |
Capitalises the first letter / every word | ucwords('hello world') → 'Hello World' |
strtolower() / strtoupper() |
Changes letter case | strtolower('HeLLo') → 'hello' |
hash_equals($known, $user) |
Timing-attack-safe comparison | for tokens and HMACs, never === |
The last row deserves a comment. hash_equals() always takes the same amount of time to compare two strings,
regardless of which character they differ at. A plain === stops at the first difference — and by measuring how long
the server took to respond, an attacker can guess a secret character by character. That’s why tokens, signatures and
password hashes are compared exclusively with hash_equals().
Pitfalls from this table
strpos('Hello', 'H'); // 0 — and that is FALSY. Always compare with !== false // or use str_contains() when you only want yes/no
str_replace('l', 'L', $s, $n); // $n is BY REFERENCE — it receives the number of replacementsexplode('', 'abc'); // ValueError — empty separatorsprintf('%1$s %1$s', 'hi'); // 'hi hi' — %N$s reuses argument number Ntrim($s, 'abc'); // the second argument is a LIST OF CHARACTERS, not a substringrtrim('file.php', 'hp.'); // 'file' — strips the characters h, p and dot, not the suffix '.php'number_format($n, 2, ',', ' '); // 3rd and 4th arguments: decimal and thousands separatorsThe first one is a classic. strpos() returns 0 when the substring is at the very beginning — and 0 is a falsy
value, so if (strpos($s, $n)) will miss a match at position zero. Since PHP 8 you have str_contains(), and in most
cases that’s the answer.
The last argument of trim() regularly catches people out, too: it’s a set of characters to strip, not a suffix.
rtrim('file.php', '.php') works by accident, but rtrim('graph.php', '.php') returns 'gra', because it also strips
the h from graph.
Bytes versus characters
All functions without the mb_ prefix operate on bytes, not characters. For ASCII it makes no difference. For
anything with accented letters — Polish text, for instance — the difference is huge.
strlen('żółw'); // 7 — bytes in UTF-8 (żółw is Polish for "turtle")mb_strlen('żółw'); // 4 — charactersThe consequences show up wherever you cut or measure user-provided text:
substr('żółw', 0, 3); // 'żó' + half a character — broken UTF-8mb_substr('żółw', 0, 3); // 'żół'
strtoupper('żółw'); // 'żółw' — won't touch the accented lettersmb_strtoupper('żółw'); // 'ŻÓŁW'
str_pad('żółw', 6); // adds 0 spaces — because strlen is already 7The
mb_*functions aren’t part of core PHP — they come from the mbstring extension, an additional module that teaches PHP to count characters instead of bytes. Without it,mb_strlen()simply doesn’t exist and you’ll get an error about an undefined function. It’s usually already enabled, and you can check withextension_loaded('mbstring').Once you have it, treat
mb_*as the default choice for anything that comes from the user or ends up in front of them. Leave the byte functions for binary data.
Null
null is a type with exactly one value. On top of that, PHP has a set of constructs that differ in subtle ways — and
those differences are exactly where bugs come from.
isset($x); // false for null; does NOT warn about an undefined variableempty($x); // true for every falsy value; doesn't warn eitheris_null($x); // true only for null; WARNS when $x doesn't exist
$x ?? 'default'; // substitutes when $x doesn't exist or is null; doesn't warn$x ??= 'default'; // the same, but assigns the result to $x right away (7.4)$obj?->method(); // nullsafe operator (8.0)| Check | Undefined | null |
'' |
0 |
'0' |
[] |
|---|---|---|---|---|---|---|
isset() |
false |
false |
true |
true |
true |
true |
empty() |
true |
true |
true |
true |
true |
true |
is_null() |
⚠ warning | true |
false |
false |
false |
false |
?? substitutes |
yes | yes | no | no | no | no |
This table says it all: isset() asks about existence, empty() about truthiness. If you care about 0 and
'0' being valid values — and with quantities, prices and IDs you do — empty() is the wrong tool.
The nullsafe operator
$country = $user?->getAddress()?->country;The key property: ?-> short-circuits the whole chain. If $user is null, getAddress() doesn’t run at all,
country isn’t read, and the whole expression evaluates to null. You don’t need to put ?-> on every link “just in
case” — only where a null can actually appear.
?? versus ?:
Both shorten “take this, and if it’s not there, take that”. They differ in what “not there” means to them.
$v = $a ? $a : $b; // full form: if $a is truthy, take $a, otherwise $b$v = $a ?: $b; // the same, only shorter — no need to write $a twice$v = $a ?? $b; // similar, but checks SOMETHING ELSE?: — the Elvis operator, because turned sideways it resembles his quiff and eyes — looks at truthiness,
meaning it checks exactly the same thing as if ($a). It substitutes the fallback for every falsy value from the list
at the beginning of this part.
?? looks at existence. It substitutes only when the variable or key doesn’t exist at all, or when it holds
null.
It’s easiest to see with three cases of the same URL parameter:
// 1. the parameter isn't in the URL$params = [];$params['page'] ?: 1; // 1 + Warning: Undefined array key "page"$params['page'] ?? 1; // 1 — no warning
// 2. the parameter is there, but empty: ?page=$params = ['page' => ''];$params['page'] ?: 1; // 1 — '' is falsy, so the fallback kicks in$params['page'] ?? 1; // '' — the key exists, so it returns whatever is in it
// 3. the parameter is zero: ?page=0$params = ['page' => '0'];$params['page'] ?: 1; // 1 ← TRAP: '0' is falsy, so page zero disappears$params['page'] ?? 1; // '0' — correctYou almost always mean ??: input data is either there or it isn’t, and '' or '0' are often valid values. Reach
for ?: when you genuinely care about truthiness — for example $name ?: 'Anonymous', where an empty string really
should be replaced.
Finally, a trap that can catch you out when porting code from another language: nested ternary operators require
parentheses since PHP 8. They used to work, but they associated to the left — the opposite of most languages — so
a ? b : c ? d : e meant (a ? b : c) ? d : e. PHP warned about it since 7.4, and since 8.0 it’s a syntax error:
// ❌ PHP 8: Unparenthesized `a ? b : c ? d : e` is not supported$label = $score > 90 ? 'great' : $score > 50 ? 'decent' : 'poor';
// ✅ parentheses say explicitly what goes with what$label = $score > 90 ? 'great' : ($score > 50 ? 'decent' : 'poor');
// ✅ usually more readable — more on match in part four$label = match (true) { $score > 90 => 'great', $score > 50 => 'decent', default => 'poor',};What’s next
You now know what PHP keeps in variables and by which rules it turns one thing into another. What’s missing is the structure that handles pretty much everything in PHP: the array.
In the next part you’ll see why a single construct plays the role of a list, a map, a set, a stack and a queue at
once in PHP, how keys work (and why '1' and 1 are the same key), and which of the dozens of array_* functions
are actually worth knowing.