deftcode
Polski
← Back to blog

PHP in a nutshell: Data types and casting

Read this in Polish
phpphp coursetypes

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 form

The 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.

src/Example.php
<?php
declare(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 expression
var_dump('5' == 5); // true — comparison works as always, too

This 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 use
get_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); // NULL
var_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') is true, and so is is_numeric(' 12'), because a leading space is allowed. is_numeric('12 '), on the other hand, with a trailing space, only returns true since 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 keys

Two 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 base
floatval('1.5abc'); // 1.5
strval(3.0); // '3'
boolval('0'); // false
settype($x, 'integer'); // changes the type of the variable $x itself, returns bool

intval() 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 stdClass

In practice you’ll run into this with JSON, because json_decode() returns stdClass by default, not an array:

json_decode($json); // stdClass — BY DEFAULT
json_decode($json, true); // associative array

You almost always want true as the second argument — but not because an array is “safer”. Neither an array nor stdClass has 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 a null value. The array’s advantage is purely practical — ??, destructuring and the whole family of array_* 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.

false
0
-0
0.0
-0.0
''
'0'
[]
null

Everything 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 included
0.00001
[0] // an array with a single falsy element — the array itself isn't empty
new stdClass() // every object, even one without properties

The 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 falsy
if ($qty !== '') { } // ✅ an explicit check for what you actually mean
if (isset($qty)) { } // ✅ when you're asking about existence, not value

Comparisons: == 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 8
0 == 'foobar'; // true false
0 == ''; // true false
'abc' == 0; // true false

The 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, too
100 == '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 CPU
PHP_INT_MIN; // -9223372036854775808 — and the smallest
PHP_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:

// Java
Integer.MAX_VALUE // 2147483647
Integer.MAX_VALUE + 1 // -2147483648 ← wrapped around to the other end of the range

PHP 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 int

There’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 = 10
0o17; // octal = 15 (syntax from PHP 8.1)
017; // octal = 15 (old syntax — easy to overlook)
0x1A; // hexadecimal = 26
1_000_000; // digit separator (7.4+) = 1000000

The 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' gives 2, not 200 — the cast stops at the first non-digit character.


Floating-point numbers

PHP_FLOAT_MAX; // 1.7976931348623157E+308 — the largest number a float can hold
PHP_FLOAT_MIN; // 2.2250738585072E-308 — the smallest POSITIVE one, not the smallest overall
PHP_FLOAT_DIG; // 15 — how many significant digits are still reliable
PHP_FLOAT_EPSILON; // 2.220446049250313E-16 — the smallest difference a float can tell apart at all

Notice 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); // false

This 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 like
var_dump(0.1 + 0.2); // float(0.30000000000000004)
printf('%.20f', 0.1 + 0.2); // 0.30000000000000004441 ← what it REALLY is

echo 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); // true

The 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 trap

Money

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 arithmetic
bcadd('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 round
intdiv(10, 0); // DivisionByZeroError — division by zero throws an exception
10 % 3; // 1 — remainder, works ONLY on integers
10.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 right
fmod(10.5, 3); // 1.5 — remainder for floats, keeps the fractional part
fdiv(1, 0); // INF — the only division by zero that does NOT throw
fdiv(-1, 0); // -INF
fdiv(0, 0); // NAN — because 0/0 has no meaningful result
floor(7.9); // 7.0 — rounds down, but returns a FLOAT, not an int
ceil(7.1); // 8.0 — rounds up, also a FLOAT
round(2.5); // 3.0 — rounds halves up, i.e. away from zero
round(-2.5); // -3.0 — which is why minus two and a half goes to minus three
round(2.345, 2); // 2.35 — the second argument is the number of decimal places

Two 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 / 0 now throws a DivisionByZeroError instead of emitting a warning and returning INF. If you deliberately want INF, use fdiv().

NAN; // constant: "not a number" — the result of an operation with no meaningful value
INF; // constant: infinity
log(-1); // NAN — the logarithm of a negative number doesn't exist
sqrt(-1); // NAN — neither does the square root of one
PHP_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 infinity
is_finite(1.5); // true — an ordinary number: neither NAN nor infinity

NAN 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\n
echo "Hello $name\n"; // Hello Paweł + a newline character

Single 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 required
echo "Array: $arr[key]"; // key WITHOUT quotes — simple syntax only
echo "Array: {$arr['key']}"; // brace syntax — with quotes

A 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 replacements
explode('', 'abc'); // ValueError — empty separator
sprintf('%1$s %1$s', 'hi'); // 'hi hi' — %N$s reuses argument number N
trim($s, 'abc'); // the second argument is a LIST OF CHARACTERS, not a substring
rtrim('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 separators

The 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 — characters
Diagram: the word żółw as four characters and as seven UTF-8 bytes, where ż, ó and ł take two bytes each
In UTF-8, accented letters take two bytes. strlen() counts bytes, mb_strlen() counts characters.

The consequences show up wherever you cut or measure user-provided text:

substr('żółw', 0, 3); // 'żó' + half a character — broken UTF-8
mb_substr('żółw', 0, 3); // 'żół'
strtoupper('żółw'); // 'żółw' — won't touch the accented letters
mb_strtoupper('żółw'); // 'ŻÓŁW'
str_pad('żółw', 6); // adds 0 spaces — because strlen is already 7

The 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 with extension_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 variable
empty($x); // true for every falsy value; doesn't warn either
is_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' — correct

You 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.

Comments