Data types

PHP runtime types, declarations, coercion, and strict mode, with boundary techniques that preserve both type and business meaning.

level beginner time 11 min at Standard depth
version PHP 8.3.33
what

Types belong to PHP values, so a variable can hold a value of a different type later. Type declarations constrain selected positions such as parameters, returns, properties, and class constants.

trap

Casting and loose comparison discard information. A missing value, an invalid integer, and a valid zero can collapse to the same result.

fix

Validate before converting at input boundaries, use precise declarations and === in domain code, and narrow mixed as soon as you receive it.

What it is and why it exists

PHP is dynamically typed: values have runtime types, but variable names are not permanently bound to one type. $value can hold an integer and later hold a string. Whenever an expression runs, the current value’s type determines which operations and conversions apply. That flexibility suits form data, JSON, and database results, but it also postpones some mistakes until execution reaches them.

PHP also supports type declarations on parameters, return values, properties, and class constants. A declaration writes the set of allowed values into a function or object boundary, where the runtime checks it. Dynamic typing and declarations are not opposites. The former describes how variables and values behave; the latter places contracts at selected boundaries.

These are the common runtime types of PHP values. get_debug_type() reports a useful name for the current value, usually a class name for an object and a resource kind for a resource.

CategoryRuntime typesTypical use
Scalar types bool, int, float, stringOne Boolean, number, or piece of text
Compound valuesarray, objectContainers and objects with behavior
Special valuesnull, resourceA missing value or external handle

callable and iterable are closer to capability sets than separate value types reported by get_debug_type(). Both arrays and Traversable objects satisfy iterable; closures, function-name strings, and valid method arrays can satisfy callable. mixed, void, and never are also mainly declaration types. They mean any value, normal completion with no returned value, and no normal completion, respectively.

Data types answer “which values may appear here, and what can those values do?” A type cannot prove that a string came from a trusted source or that an integer lies in a business range. int $quantity excludes an array, but it does not exclude -5. Boundary validation still belongs to the application.

How it works

Values carry runtime types

Assignment binds a value to a variable name, and reassignment can change the type of the value currently associated with that name. Operators choose behavior from their operands: . performs string concatenation, arithmetic requires numeric values, and a condition interprets its value as a Boolean.

When checking a type, prefer a function that states the intent, such as is_int(), is_string(), is_array(), is_object(), is_iterable(), or is_callable(). get_debug_type() suits diagnostics and error messages. Some traditional names returned by gettype() differ from declaration syntax; an integer is reported as integer, for example. Do not turn those strings into a type-system API.

The same-looking assignment has different copying semantics for different values. Scalars and arrays behave as values, so changing a copy does not change the original variable. Object assignment copies a handle to the same object, which means both variables observe changes to that object’s properties. The explicit reference operator & can also make variables aliases, but that is a different mechanism from object handles.

Declarations constrain boundaries

An atomic declaration can be int, string, array, a class name, or an interface name, among others. A union type such as int|string accepts any one member. An intersection type such as Countable&Iterator requires an object to satisfy every member. Prefixing one type with ? is nullable shorthand, so ?string and string|null mean the same thing.

Parameters are checked when a function is entered, returns when a value is returned, and typed properties when they are assigned. PHP 8.3 also permits types on class, interface, trait, and enum constants. A failed declaration check normally throws TypeError, though scalar coercion may happen first depending on strict mode at the call site.

Declaration positionWhen it is checked
Function parameterWhen the call is entered
Return typeWhen the function returns
Object or static propertyWhen the property is assigned
Class constantWhen the class declaration is processed

These checks constrain declared positions; they do not freeze variables or recursively inspect containers. Types placed on stable module boundaries are generally more useful than casts scattered among temporary local variables.

An array declaration proves only that the outer value is an array. It says nothing about keys or element types. Native callable cannot describe a parameter and return signature, and it cannot be used as a property type. PHPDoc and static analysis can describe narrower array shapes, generic collections, and callable signatures, but runtime validation is still a separate design decision.

Context can trigger conversion

Type juggling is PHP’s automatic conversion of a value according to context. A valid numeric string becomes a number in arithmetic, an integer is treated as text when joined with ., and a condition performs Boolean conversion. Explicit casts such as (int) and (string) use the corresponding conversion rules, but “a value of the target type was produced” does not mean “the input was valid.”

Boolean context is particularly good at hiding business distinctions. false, integer 0, float 0.0, the empty string, string '0', an empty array, and null are all false; string '00' and a nonempty array are true. empty() uses similarly broad rules, so it is a poor validator for fields where zero must survive.

== converts operands according to the comparison rules, while === requires both type and value to match. Loose comparison is not a simple rule like “convert everything to text” or “convert everything to a number.” Each type pairing has its own rules. For business identifiers, status codes, and validation results, normalize to the intended type first and then use ===.

Strict mode only tightens scalar calls

declare(strict_types=1); at the top of a file enables strict types . For calls to user-defined functions made from that file, scalar arguments normally have to match the declaration exactly. The one widening exception lets an int satisfy a float declaration. The caller’s file chooses the argument rule; the file containing the function cannot choose it for every caller.

Strict mode also tightens return-type handling in the file containing the declaration, but it is not an input validator. JSON, query parameters, and form fields still enter the program as strings, arrays, integers, or null. Your code must decide which representations, ranges, and missing states are allowed. Strict mode does not inspect the elements of an array, either.

Examples

The four examples inspect runtime types, type declarations, input parsing, and comparison rules in that order. Every output below was produced with the local PHP 8.3.33 CLI.

Inspecting the current value’s type

The same variable first binds an integer and then a string with the same visible content. The array and generator have different concrete runtime types, but both satisfy iterable.

runtime_types.php
<?php

$value = 17;
printf("%s: %s\n", get_debug_type($value), $value);

$value = '17';
printf("%s: %s\n", get_debug_type($value), $value);

$items = ['draft', 'review'];
$stream = (function (): Generator {
    yield 'publish';
})();

foreach ([$items, $stream] as $collection) {
    printf(
        "%s iterable=%s\n",
        get_debug_type($collection),
        is_iterable($collection) ? 'yes' : 'no',
    );
}
int: 17
string: 17
array iterable=yes
Generator iterable=yes

The first two lines show that the type belongs to the current value, not the variable name. String '17' looks numeric, but it remains a string until a conversion occurs.

iterable describes the ability to be traversed by foreach. Inspection reports array and Generator, not two runtime values whose type is named iterable.

Expressing a contract with union and nullable types

An order identifier may be an integer from a database or a textual external identifier, so the function accepts those two forms with a union. An owner may genuinely be absent, so the other function includes null in its declaration.

declared_types.php
<?php
declare(strict_types=1);

function normalizeOrderId(int|string $id): string
{
    return is_int($id)
        ? sprintf('ORD-%05d', $id)
        : strtoupper(trim($id));
}

function displayOwner(?string $owner): string
{
    return $owner ?? 'unassigned';
}

echo normalizeOrderId(27), PHP_EOL;
echo normalizeOrderId(' web-27 '), PHP_EOL;
echo displayOwner(null), PHP_EOL;

try {
    normalizeOrderId(2.5);
} catch (TypeError) {
    echo "TypeError\n";
}
ORD-00027
WEB-27
unassigned
TypeError

is_int() narrows the union to one branch, after which both branches clearly return strings. 2.5 is neither an int nor a string, so the strict call throws TypeError before the function body runs.

A union should express real domain input, not grow indefinitely to avoid designing a boundary. If every caller can normalize to one identifier representation first, accepting one value object or string is simpler.

Validate external input before converting it

Query parameters commonly enter a program as strings. This parser accepts mixed, then immediately checks the allowed input types, integer syntax, and nonnegative range.

parse_quantity.php
<?php
declare(strict_types=1);

function parseQuantity(mixed $raw): int
{
    if (is_int($raw)) {
        $quantity = $raw;
    } elseif (is_string($raw)) {
        $parsed = filter_var($raw, FILTER_VALIDATE_INT);
        if ($parsed === false) {
            throw new InvalidArgumentException('not an integer');
        }
        $quantity = $parsed;
    } else {
        throw new InvalidArgumentException('wrong input type');
    }

    if ($quantity < 0) {
        throw new InvalidArgumentException('negative quantity');
    }
    return $quantity;
}

foreach (['12', '0', '12 boxes', null] as $raw) {
    try {
        printf("%s => %d\n", get_debug_type($raw), parseQuantity($raw));
    } catch (InvalidArgumentException) {
        printf("%s => invalid\n", get_debug_type($raw));
    }
}
string => 12
string => 0
string => invalid
null => invalid

Valid string '0' survives as integer 0, while the string with trailing text and null are rejected. A direct (int) ($raw ?? 0) would collapse distinct error states into one integer, leaving callers unable to tell them apart.

Here mixed means that the boundary value is not trusted yet, not that later code may perform arbitrary operations on it. Only after the branches finish does $quantity carry the int meaning that domain code can rely on.

Separate truthiness from equality

A conditional position tests truthiness, while a comparison operator relates two operands. Both can involve conversion rules, but they answer different questions.

comparisons.php
<?php

$values = ['', '0', '00', [], [0]];

foreach ($values as $value) {
    printf(
        "%s => %s\n",
        json_encode($value),
        $value ? 'true' : 'false',
    );
}

var_export('10' == 10);
echo PHP_EOL;
var_export('10' === 10);
echo PHP_EOL;
"" => false
"0" => false
"00" => true
[] => false
[0] => true
true
false

String '0' is PHP’s unusual nonempty false string, while '00' is true. Testing a business code with if ($code) can reject a valid string '0'; test null, the empty string, or the required format according to the contract instead.

The final two values are equal but have different types. Once an input has been normalized, === prevents the comparison itself from introducing another implicit conversion.

Pitfalls

Treating a cast as validation

Fix: constrain the allowed input types and text syntax first, then convert and check the range. The interface contract should say whether an error produces a result object or a domain exception.

Expecting strict_types to sanitize request data

Fix: create a parsing layer at HTTP, CLI, message-queue, and database boundaries. Once that layer returns narrow types, strict declarations on internal functions have stable meaning.

Depending on loose comparison and empty()

Fix: write down the accepted representations and missing-value policy, normalize once, and use ===, is_*(), or a domain validator. One broad condition is not a data contract.

Assuming array includes an element type

Fix: validate the shape at a boundary and use named objects for stable domain data. PHPDoc array shapes and generics help static analysis, but they do not replace runtime checks.

Treating object assignment as object copying

Fix: use clone when you need a new identity, and define a cloning policy for mutable objects held inside it. When sharing is intended, make ownership clear through names and interfaces instead of leaving readers to guess.

Deep Runtime values and declaration types

Runtime values and declaration types

Runtime value types and declaration types overlap, but they are not one enumeration. Keeping them separate prevents the wrong names from leaking between reflection, error messages, and static analysis.

Atomic declarations and capability sets

bool, int, float, string, array, object, null, and class or interface names can appear in suitable userland declaration positions. resource is the exception: it is a runtime type but cannot be used as a userland type declaration. A resource-accepting boundary usually takes mixed, then checks is_resource() and get_resource_type().

iterable is equivalent to accepting an array or an object implementing Traversable, so a value’s concrete type remains an array or class. callable accepts a value callable from the current scope, but native syntax cannot constrain its parameter and return signature. Callability also depends on scope and visibility, so an external string should not become a trusted callback merely because it has the right shape.

callable cannot be a property type because the stored value cannot be guaranteed to remain callable from every scope. If a property holds a closure, it can be declared as Closure. A more general callback should be validated at the construction boundary and converted to a clear internal representation.

Unions, intersections, and DNF

A union uses | to mean “satisfies at least one member.” PHP rejects some obviously redundant combinations at compile time, including int|INT and bool|false. Because mixed already includes every value, it cannot be combined with another union member either.

An intersection uses & to mean “satisfies every member.” Its members must be class or interface types, so int&Countable is invalid. Intersections suit APIs that require an object with two independent capabilities; they do not express array element constraints.

Since PHP 8.2, a disjunctive normal form type can combine intersections and unions, as in (Countable&Iterator)|array. The intersection must be parenthesized. Keep the expression narrow enough to explain aloud; once the signature becomes a puzzle, a named interface is usually easier to maintain.

mixed, void, and never

mixed accepts every value, including null. It suits a boundary that has not been parsed or a truly transparent forwarding layer, but it transfers narrowing responsibility to the function body. A function that accepts mixed and immediately performs arithmetic or member access usually has an unfinished contract.

void is return-only and says that normal completion provides no result value. A function may execute bare return;, but it cannot return an expression. Calling a void function still produces null as an implementation result, though callers should not treat that result as API data.

never is also return-only and says that the function cannot complete normally, for example because it always throws or terminates the process. A never function violates its declaration if it reaches the end or tries to return normally. It describes control flow, not a value that a variable can store.

Conversion cannot preserve provenance

Conversion rules see only the current value. They do not know whether it came from trusted configuration or a user request. Once a string becomes an integer, its original spelling, leading characters, missing state, and any truncated part may be gone. Conversion should happen only after the representation has been accepted.

Numeric strings and arithmetic

A well-formed numeric string can participate in arithmetic and becomes an int or float according to its contents. A nonnumeric string in PHP 8 arithmetic raises TypeError; a string with trailing nonnumeric content also produces a diagnostic. Relying on conversion during arithmetic lets expression side effects define the validation policy.

An explicit parser can handle syntax errors, range errors, and missing values separately. Monetary values usually need decimal-scale and rounding rules as well; converting to a binary float first does not make them exact. A data type provides storage, while domain rules still have to be stated.

Boolean conversion is not text parsing

A Boolean cast does not interpret natural-language words as true and false. Every nonempty string outside the false-value set is true, so (bool) 'false' and (bool) 'no' are both true. For form Boolean fields, enumerate the permitted text or use a validator with an explicit policy.

Likewise, empty() answers PHP’s broad truthiness question, not “does the field exist?” or “is the text blank?” Array-key existence, nullable values, and whitespace-only strings need separate checks, or valid zero values will easily be mistaken for missing values.

Normalize before comparison

Loose comparison tables vary with the operand pairing. Memorizing a few isolated examples does not create a reliable contract. A sturdier boundary parses input into one known type and uses strict comparison inside.

If an interface genuinely accepts several representations, such as integer 10 and string '10' for the same identifier, its normalization function should document that policy and return one type. Conversion then happens once, so later authorization, lookup, and branching do not each choose a different rule.

Strict mode at file boundaries

strict_types is a file-level directive, and the argument rule follows the file where the call occurs. A function defined in a strict file can still receive coerced scalar arguments when a non-strict file calls it. Conversely, a strict file uses strict argument checks when it calls a user-defined function declared elsewhere.

This design means a library author cannot force every caller to use strict scalar arguments merely by adding the directive to library files. The library should still declare accurate types because the contracts and TypeError failures matter, but external data must be parsed at an explicit library or application boundary.

Calls made by internal functions have their own rules, so do not extrapolate from the call-site rule for user-defined functions. When a boundary behavior matters, run a minimal example on the target PHP version and check the official manual instead of letting generated code guess from another language’s strict mode.

Strict does not mean static

Enabling strict mode does not turn PHP into a statically typed language. Variables can still be rebound to different types, undeclared positions can still hold any value, and many mistakes still surface only when execution reaches the relevant path.

A static analyzer can use native declarations, PHPDoc, and control flow to find more problems earlier, but its conclusions depend on truthful annotations. Asserting an unvalidated mixed value into a narrow type in a comment only hides risk. Runtime parsing and the static contract need to describe the same boundary.

Further reading

checkpoint

5 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?