Advanced Types: never, mixed, static/self/parent, Unions, Intersections and DNF

Understand PHP's more advanced type-system vocabulary — never, mixed, the three self-referencing return types, union/intersection/DNF types, and literal booleans/null — well enough to write function signatures that are both precise and honest about what a function can and cannot return, and to reason correctly about variance in inheritance.

Basic PHP typing (int, string, ?Foo, Foo|null) gets you a long way, but real-world APIs — especially fluent builders, factory methods, and libraries with fine-grained contracts — need a richer vocabulary. PHP 8.0 through 8.2 added exactly that vocabulary: never for functions that don't return at all, mixed for functions that genuinely accept or produce anything, static as a proper return type for fluent method chains, intersection and DNF (disjunctive normal form) types for describing values that must satisfy several contracts at once, and stricter rules for how types may narrow or widen across inheritance. This lesson treats each of these as a tool with a specific job, not just syntax to memorize.

never: functions that do not return control at all

The never return type, added in PHP 8.1, declares that a function never returns control to its caller — either it always throws, always calls exit/die, or always loops forever. This is different from void, which means 'returns no useful value' but still returns control normally; never means execution never reaches the line after the call. The distinction matters to static analyzers and to human readers: a function typed never tells you, at the call site, that any code physically after the call is unreachable, and tools like PHPStan and Psalm will flag dead code that follows it.

A classic use is a redirect() helper, an assertUnreachable() guard in a match/switch's default branch, or a validation function like failIfInvalid(array $data): never that always throws on bad input. If a function typed never ever actually falls through and returns, PHP raises a fatal error at runtime — the engine enforces the contract, it isn't just documentation.

• never = 'this call never returns control here' (always throws/exits/infinite-loops). • void = 'this call returns control, but produces no meaningful value.' • A function declared never that reaches an implicit or explicit return is a fatal error. • Static analysis tools use never to flag genuinely unreachable code after a call.

mixed: an explicit 'could be anything', not an admission of defeat

mixed, also from PHP 8.0, is shorthand for the union of every possible type: object|resource|array|string|int|float|bool|null. Before mixed existed, an untyped parameter or return value was simply the absence of a type declaration, which strict-typing tools could not distinguish from 'the author forgot to type this.' Declaring mixed explicitly says: I have deliberately considered this value's type and it can genuinely be anything — the caller must narrow it before doing anything type-specific with it.

mixed is most defensible at true boundaries: decoding untyped JSON, reading a generic cache/key-value store, or implementing an interface method whose contract genuinely allows any value (like a generic Collection::add(mixed $item)). One important rule reflects that mixed is the top of PHP's type hierarchy: you cannot narrow mixed by declaring a subtype in a child class's override going the wrong direction, and a parameter typed mixed can be overridden by a child only with an equally- or less-restrictive type, following the variance rules covered later in this lesson.

The discipline mixed enables is that you immediately narrow it — with is_string(), is_int(), instanceof, or a match expression — before using the value in a type-specific way. Passing a mixed value straight into a function expecting string without narrowing first defeats the entire purpose of typing it.

• mixed is exactly equivalent to object|resource|array|string|int|float|bool|null. • Prefer a precise union (e.g. int|string) over mixed whenever the real possibilities are known and finite. • mixed signals an intentional decision, not the absence of one — always narrow it before use.

static, self, and parent as return and reference types

self, parent, and static all refer to classes relative to where code is written, but they resolve very differently. self always refers to the class in which the method is literally defined, resolved at compile time — it ignores which subclass actually called it. parent refers to the immediate parent class, used to call an overridden method's original implementation (parent::__construct()). static, by contrast, uses 'late static binding': it resolves at runtime to whatever class was actually instantiated or called, which makes it the correct choice for fluent builder methods and named constructors that should work correctly in subclasses.

As a return type, return self locks a method's return value to the exact defining class, which silently breaks fluent chains in subclasses (a subclass instance would claim to return the parent type). return static instead promises 'an instance of whatever class this method was actually called on', which is exactly right for methods like withName() on an immutable value object, or a create() named constructor meant to be inherited and still return the correct subclass.

• self = the defining class, resolved at compile time — ignores the actual runtime class. • parent = the immediate superclass — used to reach an overridden method's base implementation. • static = late static binding — resolved at runtime to the actual instantiated/called class. • Fluent/chainable methods and named constructors (create(), from()) should almost always return static, not self.

Union, intersection, and DNF types

Union types (int|string), stable since PHP 8.0, say a value may be any one of the listed types. Intersection types, added in PHP 8.1, say the opposite: a value must simultaneously satisfy every listed type. Intersection types only work with interfaces/classes (Countable&Iterator), never with scalars, because a scalar value cannot literally be two different scalar types at once — but an object certainly can implement two interfaces at once.

PHP 8.2 introduced DNF (disjunctive normal form) types, which let you combine unions and intersections in the one shape the engine can unambiguously parse: an 'or' of terms, where each term is either a single type or a parenthesized intersection. For example, (Countable&Iterator)|null means 'either something that is both Countable and Iterable, or null.' You cannot arbitrarily nest unions inside intersections (A|(B&C)|(D&E) is fine; deeper nesting is not) — DNF is intentionally restricted to keep type resolution decidable.

These forms exist to describe real contracts precisely instead of falling back to a loose interface or mixed. A function that needs to both iterate a value and count it should declare Countable&Iterator directly rather than defining a new marker interface just to express that combination, and a function that accepts either that combination or an explicit absence of a value should use the DNF form (Countable&Iterator)|null.

• int|string — union: may be either type. • Countable&Iterator — intersection: must implement both interfaces at once. • (Countable&Iterator)|null — DNF: 'either both interfaces together, or null.' • Intersection types cannot include scalar types (int, string, etc.) — only classes/interfaces.

Literal true/false and null as standalone types

PHP 8.2 also allows true, false, and null to appear as standalone, literal types in a signature — not just as members of bool or as the nullable ? prefix. This is most useful in unions where a function's return value narrows to a specific literal depending on success or failure, e.g. array|false for a legacy-style function that returns an array on success and the literal value false on failure (mirroring how many built-in PHP functions like strpos() historically behave), or true alone as a return type for a method that, by contract, only ever returns the boolean true (throwing on any failure instead of returning false).

Declaring false as its own type is also how PHP's own engine and IDEs now describe legacy standard-library functions internally, and it is a useful pattern for your own code migrating away from ambiguous boolean-or-value returns: it documents, in the signature itself, exactly which literal value signals failure.

Type variance: how types may change across inheritance

When a child class overrides a parent method, PHP enforces variance rules to guarantee the child can always be used anywhere the parent is expected (the Liskov substitution principle). Return types are covariant: an override may narrow its return type to a more specific subtype than the parent declared, because any caller expecting the parent's broader type can still safely accept the more specific one. Parameter types are contravariant: an override may only widen a parameter type to something equally or less specific than the parent declared, because the method must still accept every value a caller relying on the parent's contract might pass in.

Concretely: if Animal::reproduce(): Animal is overridden as Cat::reproduce(): Cat, that's legal covariance — a Cat is-an Animal, so returning the narrower type is safe. But if Shape::resize(int $factor): void were overridden as Square::resize(string $factor): void, that would be illegal — it's neither the same type nor a widening, and PHP raises a fatal 'must be compatible' error at class-definition time. Getting variance backwards is one of the most common sources of confusing 'declaration must be compatible with parent' fatal errors for developers new to PHP's type system.

• Covariant return types: a child method MAY return a narrower (more specific) type than its parent. • Contravariant parameter types: a child method MAY accept a wider (or equally specific) parameter type, never narrower. • PHP enforces this at class-declaration time — violations are fatal errors, not warnings.

Advanced type declarations at a glance

static as a return type is almost always correct for fluent builders and named constructors — self would silently break subclasses. mixed and mixed-typed unions still require you to narrow the value with is_*()/instanceof before using it concretely. Intersection and DNF types only combine class/interface types — never scalars like int or string.

final class Failure
{
    public static function assertUnreachable(string $msg): never
    {
        throw new \LogicException($msg);
    }
}

function decodeAnything(string $json): mixed
{
    return json_decode($json, true); // caller must narrow the result
}

abstract class Builder
{
    public static function create(): static   // late static binding
    {
        return new static();
    }

    public function withName(string $name): static
    {
        $clone = clone $this;
        $clone->name = $name;
        return $clone; // returns the ACTUAL subclass, not Builder
    }
}

function processStream(Countable&Iterator $stream): int
{
    return count($stream);
}

function findFirst(array $items, callable $match): (Countable&Iterator)|null
{
    // DNF: either a Countable+Iterator collection, or null if nothing matched
    return null;
}

function legacyFind(array $haystack, mixed $needle): array|false
{
    $pos = array_search($needle, $haystack, true);
    return $pos === false ? false : [$pos => $haystack[$pos]];
}

Code Examples

fail() is typed never because every code path inside it ends in a throw — it can never return normally. Because the engine and static analyzers know this, the line echo 'Order accepted' in ensureValid() is correctly understood as reachable only when fail() was never called, which is exactly the intended control flow. Expected result: Order accepted private function fail(string $reason): never { throw new \InvalidArgumentException($reason); }

Because where() is typed to return static rather than self, calling it on a SoftDeleteQueryBuilder instance returns another SoftDeleteQueryBuilder, not a plain QueryBuilder. Late static binding resolves static to the class actually instantiated at runtime, so the chained call to excludeDeleted() — a method that only exists on the subclass — type-checks correctly. Expected result: bool(true) public function where(string $clause): static {... return $clone; }

Common mistake: Typing where() to return self instead of static would make $q statically (and in some tooling, at runtime-adjacent analysis) a QueryBuilder, so calling ->excludeDeleted() on the chain result would be flagged as calling an undefined method.

summarize() requires a value that is simultaneously Countable and Iterator — the intersection type makes that dual requirement explicit in the signature rather than relying on a comment or a runtime instanceof check that callers might miss. findMatching() extends that with DNF syntax to say the return is either that same intersection, or nothing at all.

function summarize(\Countable&\Iterator $collection): string

final class OrderValidator
{
    public function ensureValid(array $order): void
    {
        if (empty($order['items'])) {
            $this->fail('Order has no items');
        }
        // static analyzers know execution can ONLY reach here if $order was valid
        echo 'Order accepted' . PHP_EOL;
    }

    private function fail(string $reason): never
    {
        throw new \InvalidArgumentException($reason);
    }
}

$validator = new OrderValidator();
$validator->ensureValid(['items' => ['pen']]);
class QueryBuilder
{
    protected array $wheres = [];

    public function where(string $clause): static
    {
        $clone = clone $this;
        $clone->wheres[] = $clause;
        return $clone;
    }
}

class SoftDeleteQueryBuilder extends QueryBuilder
{
    public function excludeDeleted(): static
    {
        return $this->where('deleted_at IS NULL');
    }
}

$q = (new SoftDeleteQueryBuilder())
    ->where('active = 1')
    ->excludeDeleted(); // still a SoftDeleteQueryBuilder, thanks to `static`

var_dump($q instanceof SoftDeleteQueryBuilder);
interface Countable2 extends \Countable {}

function summarize(\Countable&\Iterator $collection): string
{
    $count = count($collection);
    $first = $collection->valid() ? $collection->current() : null;
    return "count={$count} first=" . ($first ?? 'none');
}

function findMatching(array $pool, callable $predicate): (\Countable&\Iterator)|null
{
    foreach ($pool as $candidate) {
        if ($candidate instanceof \Countable && $candidate instanceof \Iterator && $predicate($candidate)) {
            return $candidate;
        }
    }
    return null;
}

Variance direction across an override

Parameters may only get wider (or stay the same) going down the hierarchy; return types may only get narrower (or stay the same). Getting this backwards is what triggers PHP's 'declaration must be compatible with parent' fatal error.

Parent::method(ParamType $x): ReturnType

        parameters                    return type
   PARENT <--- widen only        PARENT ---> narrow allowed
     ^   (contravariant)                        v (covariant)
     |                                           |
  Child::method(WIDER-or-same $x): NARROWER-or-same

Important Rules

Use never only when a function has no path that returns control normally — always throws, exits, or loops forever.

Prefer a precise union type over mixed whenever the real set of possible types is small and known.

Return static (not self) from fluent methods and named constructors meant to work correctly in subclasses.

Use intersection types (A&B) to require multiple interfaces at once; combine with unions only via DNF's parenthesized form.

Remember covariance for returns (narrower is OK) and contravariance for parameters (wider is OK) when overriding methods.

Where this shows up

Modern PHP frameworks lean heavily on this vocabulary: ORM query builders and HTTP request builders return static so chained calls keep the subclass's extra methods available; validation and routing libraries use never-typed abort/redirect helpers so static analysis can flag genuinely dead code after them; and interfaces describing 'something both countable and traversable' (like paginated result sets) are expressed with intersection types instead of ad hoc marker interfaces.

abstract class Model { public static function make(): self { return new static(); } } abstract class Model { public static function make(): self { return new self(); } } // or: return type static with new static()

Declaring the return type self but constructing with new static() is internally inconsistent and misleading to callers: a subclass calling Product::make() really does get a Product instance at runtime, but the declared type claims it's only ever a Model, causing IDEs and static analyzers to reject perfectly valid subclass usage. Either use self with new self() consistently, or (almost always preferable) use static with new static() throughout.

class Repo { public function save(int|string $id): void {} } class CachedRepo extends Repo { public function save(int $id): void {} } // narrower parameter class CachedRepo extends Repo { public function save(int|string $id): void {} } // same or wider

Narrowing a parameter type in an override violates contravariance and is a fatal 'declaration must be compatible' error: any code holding a Repo reference might legally call save('abc'), and a CachedRepo substituted in must still accept that call.

A method is declared to return never. What must be true of every execution path inside it? A. It must return null on every path B. It must never return control to the caller — it always throws, exits, or loops forever C. It must return void but may optionally print output D. It must be declared abstract

B. It must never return control to the caller — it always throws, exits, or loops forever — never means the function never hands control back to the caller normally; PHP raises a fatal error at runtime if a never-typed function ever actually reaches a return.

Why should a fluent builder method typically return static instead of self? A. static is faster at runtime than self B. self is deprecated in PHP 8 C. static resolves via late static binding to the actual runtime class, so chained calls still work correctly on subclasses D. There is no difference; they are aliases

C. static resolves via late static binding to the actual runtime class, so chained calls still work correctly on subclasses — self is resolved at compile time to the defining class, while static uses late static binding to resolve to whatever class was actually instantiated — essential for fluent APIs to remain correctly typed in subclasses.

Which of the following is a valid PHP 8.2 intersection/DNF type? A. int&string B. (Countable&Iterator)|null C. static&self D. mixed&int

B. (Countable&Iterator)|null — Intersection types only apply to classes/interfaces, never scalars, so int&string is invalid. (Countable&Iterator)|null is valid DNF: an intersection wrapped in parentheses, unioned with another type.

A parent method declares save(int|string $id): void. Which override is legal under PHP's variance rules? A. save(int $id): void — narrowing the parameter to just int B. save(int|string|float $id): void — widening the parameter C. save(string $id): void — changing to a different single type D. Overriding parameter types is never allowed in PHP

B. save(int|string|float $id): void — widening the parameter — Parameters are contravariant: an override may only accept an equally wide or wider type than the parent, never a narrower one. Widening to int|string|float is legal; narrowing to just int or swapping to string is not.

Write an abstract class Shape with an abstract method area(): float, and a method describe(): never — no wait, correct that: write a method assertPositiveArea(): void that calls a private helper failNegativeArea(float $area): never (which throws) whenever area() returns a negative value. Then write a Circle extends Shape with a radius property, implement area(), and a static named constructor fromDiameter(float $d): static that returns the correct subclass.

abstract class Shape { abstract public function area(): float; public function assertPositiveArea(): void { if ($this->area() < 0) { $this->failNegativeArea($this->area()); } } private function failNegativeArea(float $area): never { throw new \DomainException("Area cannot be negative: {$area}"); } } class Circle extends Shape { public function __construct(private float $radius) {} public function area(): float { return M_PI * $this->radius ** 2; } public static function fromDiameter(float $d): static { return new static($d / 2); } }

The never-typed helper should contain only a throw statement; fromDiameter should use new static($d / 2), not new self(...), so further subclasses of Circle would still work correctly.