Assignment Decides the Type: PHP's Dynamically Typed Variables
<?php
$user = 'ada'; // string
$user = 42; // same variable, now an int
$user = [1, 2, 3]; // now an array
PHP variables have no declared type. A $ sigil plus an assignment is the entire declaration ceremony: the variable holds whatever type the last assignment put there, and it changes freely. There is no int x; line, no reservation of memory, no compiler error when a variable changes type mid-function. That flexibility is why a three-line script starts instantly — and why the type of a variable is a property of the program's state, not of its source code, which is exactly what an interviewer will push on: "how do you know what $user holds here?" The honest answer is always "whatever the last assignment gave it — and that is a bug risk you manage with naming discipline and, later, function signatures."
Some rules that shape daily code:
$Count = 1; // variable names are case-sensitive — $count is a different variable
$_total = 2; // letters, digits, underscore; cannot start with a digit
unset($user); // removes the variable entirely
$qty = $_GET['qty'] ?? 1; // read input safely; see below
Undefined variables. Reading a variable that was never set is a warning in PHP 8 (it was a notice before) that evaluates to null:
Warning: Undefined variable $qty in script.php on line 3
The script continues with null, which is convenient in prototypes and a footgun in production. The ?? operator is the idiomatic guard: $qty = $_GET['qty'] ?? 1; reads "use the request value, else 1" with no warning at all. Chaining works — $a ?? $b ?? 'default' — and ?? specifically skips over null and missing keys without triggering notices, unlike $a ?: $b which also treats '', 0, and false as "empty" (a distinction worth memorizing before the conditionals topic).
Scope. Inside a function, variables are local by default; an outer variable is invisible unless passed in or imported with global $name:
$threshold = 10;
function isAbove(int $v): bool { return $v > $threshold; } // Warning: Undefined variable $threshold
Passing values as parameters instead of importing globals is the habit that keeps functions testable; global exists mainly for legacy code and the odd bootstrap script.
References. Assignment copies values, except when you ask for an alias with =&:
$a = [1, 2];
$b = &$a; // $b and $a are two names for the same zval
$b[] = 3; // $a is now [1, 2, 3]
The same operator in foreach ($rows as &$row) lets you edit elements in place — and produces the classic bug where $row still aliases the last element after the loop, so a later $row = 'x' silently rewrites it. The fix is one line: unset($row); right after the loop ends.
Finally, values you never want to change belong in constants: const MAX_RETRIES = 5; at the top level of a file (compile-time, namespace-aware) or define('APP_ENV', 'prod'); anywhere at runtime. A constant cannot be reassigned, carries a name that explains itself in a stack trace, and — unlike a variable — makes the intent legible to the next reader.
The Four Scalars and the Conversion Table Interviews Love
PHP's scalar types are int, float, string, and bool. Each has one or two behaviors that bite in production.
int is platform-sized — 64-bit on essentially every current server, giving ±9.2 quintillion via PHP_INT_MAX. Literals accept decimal, hex (0x2A), and binary (0b101010). When arithmetic overflows the range, PHP silently promotes to float rather than wrapping:
echo PHP_INT_MAX + 1; // 9.2233720368548E+18 — a float now, precision lost
Money above ~92 quadrillion, or any counter that must be exact, needs an integer string or bcmath.
float is a 64-bit IEEE 754 double (1.5, 1.0e3). Because it cannot represent most decimal fractions exactly, 0.1 + 0.2 === 0.3 is false — compare floats with an epsilon or in cents-as-integers, never with ===.
string is an immutable byte sequence; length is whatever the encoding says, so strlen('café') is 5 on a UTF-8 string, not 4 — the byte-versus-character split returns in the string topic.
bool is true/false, and its trap is PHP's notion of falsy values:
false, 0, 0.0, "", "0", null, []
Everything else is truthy — including the string "false" and the string "0.0", which are non-empty strings and therefore true. So if ($input) on a form field containing "0" silently skips, and the safe test for a user-supplied flag is if ($input === '1') or an explicit allowlist.
Conversions happen in two directions. Explicit casts are written (int), (float), (string), (bool) and follow deterministic rules:
| Expression | Result | Why |
|---|---|---|
(int) '42' |
42 |
numeric string converts exactly |
(int) '42 apples' |
42 |
leading numeric portion wins |
(int) 'apples' |
0 |
no leading number — silently zero |
(int) true |
1 |
bool to int |
(string) true |
'1' |
bool to string |
(string) false / (string) null |
'' |
empty string |
(float) '3.5xyz' |
3.5 |
same leading-number rule |
(int) 'apples' → 0 is the one to quote in interviews: it is how a non-numeric product SKU silently becomes "product zero" in a production query.
Inspection tools: gettype($v) returns 'integer', 'string'…, is_int($v) is the precise check, var_dump($v) prints type and value, and is_numeric($v) answers the looser question "could this string be a number?" — useful for validation, wrong for type checks, since is_numeric('42') is true while is_int('42') is false. Numeric strings also carry a subtle edge: since PHP 8, leading whitespace is tolerated (is_numeric(' 42') is true) but trailing junk is not (is_numeric('42 ') is false) — validation code that trims input first is doing real work.
For diagnostics, PHP 8.0 added get_debug_type($v), which returns the actual type name — 'int', 'string', 'null', and for objects the class name ('App\User') instead of gettype's useless 'object':
throw new InvalidArgumentException(
'Expected int for $id, got ' . get_debug_type($id)
);
And the practical money pattern follows directly from the float caveat: store cents as int (1275), format only at the edge with number_format($cents / 100, 2) — exact arithmetic all the way through, pretty printing exactly once.
declare(strict_types=1): Switching Coercion Off
By default PHP coerces scalar arguments to match a declared parameter type. Add one declaration and it stops:
<?php declare(strict_types=1); // must be the FIRST statement in the file
function discount(float $price, int $percent): float {
return $price * (1 - $percent / 100);
}
discount('19.99', '20'); // TypeError: Argument #1 must be of type float, string given
Without the declaration, that same call silently works: '19.99' becomes 19.99 and '20' becomes 20. Coercion is convenient for form input and catastrophic for real bugs — the typo that passes '19.99' where an id was expected becomes a valid price instead of an immediate failure.
Two facts about strict_types are interview gold:
1. It applies to the file where the call is made, not where the function is defined. If discount() lives in a strict file but you call it from a legacy file without the declaration, coercion still happens for that call. Strictness is a per-call-site switch, which is why a partially migrated codebase behaves inconsistently until every caller opts in.
2. It must be the first statement. Before any code, before any output; anywhere else it is a compile error: strict_types declaration must have 1 as its value covers the malformed forms. It cannot be applied retroactively to a running script.
What strict mode does and does not change:
// Coercive mode (default)
discount('19.99', 20); // coerced — runs
discount('abc', 20); // TypeError even here; no leading number to convert
// Strict mode
discount(19.99, '20'); // TypeError: string given, int required
Coercion still throws when conversion is impossible, so strict mode is not about "making errors possible" — it is about making wrong-but-convertible values fail instead of sliding through. The same principle governs returns: a : string function that return 42; yields '42' under coercion and a TypeError under strict. And since PHP 8.0, internal functions follow the same contract — strlen([]) is a TypeError, not a warning and null.
The bool parameter under coercion deserves its own warning, because it converts intent into nonsense:
function notify(bool $immediate = false): void { … }
notify('no'); // coercive: any non-empty string except '0' is TRUE
notify('off'); // also true
The strings 'no' and 'off' are non-empty and not '0', so they coerce to true — the exact opposite of what a caller typing --notify=no meant. This is the strongest argument for keeping boolean parameters internal to well-typed code and parsing string flags into real booleans at the CLI or form boundary, where 'no' can be matched against an allowlist before it ever reaches a signature.
The practical rollout pattern: add declare(strict_types=1); to every new file as line one, and convert existing files one at a time — each conversion is safe because it only tightens that file's call sites, surfacing mismatches exactly where you are already looking. Tools like Rector automate the mechanical part (inserting declarations, fixing flagged call sites), leaving you the interesting work of deciding what each caught mismatch should have meant. The one thing strict mode deliberately does not solve is untrusted input: $_POST['qty'] arrives as a string no matter what your signatures say, so validate and convert at the boundary ((int) ($_POST['qty'] ?? 0)) and let everything downstream stay strict.
Quiz
1. What does `(int) 'apples'` produce, and why is it dangerous?
2. `$qty = $_GET['qty'] ?? 1;` vs `$qty = $_GET['qty'] ?: 1;` — when do they differ?
3. A function declared in a file WITH strict_types is called from a legacy file WITHOUT it. Passing '19' to an int parameter does what?
4. Where must declare(strict_types=1) appear, and what happens if it is on line 5?
Flashcards
Question
How does a PHP variable get its type, and what happens on reassignment to a different type?
Click to reveal answer
Answer
From its most recent assignment — variables are never declared and hold no fixed type. Reassigning changes the type freely ($user = 'ada' then 42 then an array), so a variable's type is a fact about program state, not about the source code.
Question
List PHP's falsy values.
Click to reveal answer
Answer
false, 0, 0.0, empty string '', the string '0', null, and empty array []. Everything else is truthy — including 'false', '0.0', and any non-empty string, which is why if ($input) on a form field containing '0' silently skips.
Question
??: how does it differ from ?:
Click to reveal answer
Answer
?? (null coalescing) substitutes only for null or a missing key — legitimate 0 and '0' pass through. ?: (elvis) tests general truthiness, so 0, '', '0', false and [] all trigger the fallback. Use ?? for user input, ?: only when emptiness is truly the test.
Question
Key facts about declare(strict_types=1)
Click to reveal answer
Answer
Must be the FIRST statement of the file (else compile error). It governs the CALLING file's call sites, not the function's own file. It disables scalar coercion — mismatches throw TypeError — and applies to return values as well. Adopt per file, new files first.
Question
What happens when arithmetic exceeds PHP_INT_MAX, and when should you care?
Click to reveal answer
Answer
PHP silently promotes the int to a float, losing exact precision (PHP_INT_MAX + 1 prints 9.2233720368548E+18). Any value that must stay exact — money, large ids, counters — beyond 2^63-1 needs integer strings or bcmath, not plain ints.
Revision Notes
Key Takeaways
- 1. Variables are untyped containers whose type follows the last assignment; $Count and $count are different variables and unset() removes one entirely
- 2. Reading an undefined variable is a Warning + null in PHP 8; ?? is the safe read for missing input, ?: is the truthiness test
- 3. Falsy is exactly: false, 0, 0.0, '', '0', null, [] — every other string is truthy, including 'false' and '0.0'
- 4. Casts always succeed: (int)'apples' is 0 and overflow promotes to float — predictable rules, unpredictable consequences for unvalidated data
- 5. strict_types=1 must be line one and follows the caller; it turns wrong-but-convertible arguments into TypeErrors instead of silent coercion
Interview Tips
- • "What does (int) '10 apples' return?" — 10: the leading numeric portion wins; extend the answer with (int)'apples' = 0 to show you know the silent-zero case, the classic production bug
- • "What is falsy in PHP?" — recite the exact list (false, 0, 0.0, '', '0', null, []) and note that '0.0' and 'false' are truthy; then explain why `if ($qty)` is wrong for form input
- • "When does strict_types apply?" — to calls made from the declaring file, not calls into functions defined there; first statement only; covers arguments and return values
- • "How do you handle numeric form input?" — coerce and validate at the boundary, e.g. filter_var($_POST['qty'], FILTER_VALIDATE_INT) ?? 0, then keep everything downstream strict so internal bugs still throw
Cheat Sheet
Variables and Scalar Types
Variables: no declaration, $ sigil, type comes from the last assignment. Case-sensitive. unset() removes; reading an undefined one is a Warning + null in PHP 8.
$qty = $_GET['qty'] ?? 1; // null-coalescing: no warning, skips null/missing
$qty = $_GET['qty'] ?: 1; // ALSO treats '' , '0', 0, false as empty
$a = &$b; // alias, not copy
foreach ($rows as &$row) { … } // then unset($row); ← avoids the classic alias bug
const MAX = 5; // compile-time constant; define('X', 1) at runtime
Falsy in PHP (memorize): false, 0, 0.0, "", "0", null, [] — everything else, including "false" and "0.0", is truthy.
Scalar types & casts:
| Cast | Behavior |
|---|---|
(int) '42 apples' |
42 — leading numeric wins |
(int) 'apples' |
0 — silently zero (classic bug) |
(string) true / false / null |
'1' / '' / '' |
PHP_INT_MAX + 1 |
silently becomes a float |
gettype() for the name, is_int() for the check, is_numeric() for "could parse as number", var_dump() for both.
Strict typing:
<?php declare(strict_types=1); // FIRST statement of the file only
function f(int $x): int { … }
f('42'); // TypeError — no coercion
- Strictness follows the calling file, not the function's file.
- Coercive mode still throws TypeError when conversion is impossible (
'abc'→ int). - Returns are coerced too in coercive mode; strict makes them fail.
- Validate untrusted input at the boundary; keep internals strict.