# PHP 8.0 features

Source: https://codewiki.com/php/php8-features/

> - **what**: PHP 8.0 moves more contracts into the language: types can name several valid alternatives, calls can bind by name, metadata can be reflected as attributes, and branching and nullable calls have dedicated syntax.
> - **trap**: New syntax doesn't validate external data. Named arguments also make parameter names part of compatibility, while experience with older PHP can hide the strict semantics of `match` and nullsafe chains.
> - **fix**: Validate raw values at boundaries, test every union and `match` branch, treat public parameter names as API, and use reflection tests to check attribute targets and constructor arguments.

## What it is and why it exists

PHP 8.0 features aren't a style guide that you must adopt wholesale. They're a set of more precise language capabilities. This topic focuses on six PHP 8.0 additions that still shape everyday application code: union types, named arguments, attributes, `match`, constructor property promotion, and the nullsafe operator. PHP 8.1 enums and Fibers have their own related topics.

A union type writes several permitted runtime types as `int|string`. It lets the engine enforce existing type rules at calls, returns, and property assignments, and it exposes alternatives to static analysis. It doesn't describe an array's internal shape or automatically parse request strings.

A named argument uses `parameter: value` to send a value to a specific parameter. It's useful for skipping optional parameters in the middle of a signature and for clarifying otherwise opaque values such as booleans. The cost is that callers depend on parameter names, not just their order.

An attribute is structured metadata attached to a program entity such as a class, method, property, or parameter. A framework or application reads it through the Reflection API, then decides how routing, validation, or serialization should behave. Merely placing an attribute on a method doesn't execute any logic.

`match`, property promotion, and the nullsafe operator address three common kinds of boilerplate: strict branches from one input to one result, repeated constructor parameters and property declarations, and step-by-step checks along nullable object chains. They compress expression, not decisions. Every removed line still has semantics that need tests.

These capabilities often meet in DTOs, controllers, configuration objects, domain conversions, and framework integration. When migrating an older project, first use tests to pin down existing behavior, then adopt one feature at a time. A broad mechanical rewrite changes type failures, argument binding, comparisons, and null propagation together, making regressions hard to locate.

## How it works

The six capabilities act at different times. Type declarations participate in calls and assignments, named arguments bind parameters during a call, attributes remain in compiled metadata until a consumer reads them, `match` and the nullsafe operator work during expression evaluation, and property promotion creates and assigns properties during construction.

| Capability | Main timing | Language guarantee | Guarantee it does not provide |
| --- | --- | --- | --- |
| Union type | Call, return, property assignment | Value satisfies at least one member | External input passed domain validation |
| Named argument | Call binding | Name selects the target parameter | Parameter names may change freely |
| Attribute | Stored after compilation, read through reflection | Reflection API can discover metadata | Metadata automatically causes behavior |
| `match` | Expression evaluation | Strict comparison, one branch, a result | Compile-time exhaustiveness proof |
| Property promotion | Object construction | Declares a property and assigns the argument | Automatic domain-invariant validation |
| Nullsafe operator | Expression evaluation | Short-circuits to `null` on `null` | Missing data becomes a business default |

### Types and call boundaries

Union members are separated with `|`. A value needs to satisfy only one member; meaningless declarations, such as duplicates or combining `void` with another member, are rejected. A single nullable type is commonly written `?Customer`, while a more complex declaration can include `null` explicitly. Pick one team style where the forms are equivalent and use it consistently.

`declare(strict_types=1);` affects how calls to user-defined functions originating in the current file handle scalar arguments. It doesn't turn `int|string` into a domain enum or inspect the keys and values inside an `array`. Values from JSON, forms, and databases still need validation and conversion at the boundary before entering typed internal interfaces.

A named argument finds its parameter by name, so calls can use a different order and skip parameters with defaults. Positional arguments must precede named ones. An unknown name or assigning the same parameter twice produces an `Error`. This is real function-call binding, not associative-array configuration syntax.

Once outside callers can use named arguments, a function or method's parameter names may become public contract. Renaming `$includeTax` to `$withTax` leaves positional calls unchanged but immediately breaks `includeTax: true`. Library authors should include parameter names in compatibility review, and applications should avoid changing them repeatedly for wording alone.

### Metadata and object construction

Attributes use `#[Name(arguments)]`, and their arguments must be valid constant expressions. An attribute class is itself marked with the built-in `#[Attribute]` attribute and may declare permitted targets and repeatability. `getAttributes()` on `ReflectionClass`, `ReflectionMethod`, and related objects returns `ReflectionAttribute` descriptors.

Obtaining a descriptor doesn't construct the attribute object. Only `newInstance()` runs its constructor and can surface bad arguments, an invalid target, or a class that isn't marked as an attribute. Metadata consumers should choose when to instantiate, and they should test misconfiguration so startup failures are clear.

Constructor property promotion places a visibility modifier before a constructor parameter, as in `public string $id`. That parameter also declares a property. During construction, the argument is assigned before the constructor body runs, so the body can validate combined invariants through `$this->id`. A parameter without visibility remains an ordinary parameter.

A promoted property isn't a different kind of property. Reflection, inheritance, and type rules still treat it as an ordinary property, and an attribute can be attached directly to the promoted parameter. Don't also declare a property with the same name; the duplicate is an error, not an override or extension of the promotion.

### Strict branches and null propagation

A `match` expression compares one subject with arm conditions using `===`. The result expression of the first matching arm becomes the value of the whole `match`; there's no fall-through and no `break`. Comma-separated conditions can share one result.

If no arm matches and there's no `default`, PHP throws `UnhandledMatchError` when that path executes. That prevents an omission from silently becoming `null`, but it isn't compile-time exhaustiveness checking. Explicitly listing a closed input set and testing every member usually exposes requirement changes better than a broad `default`.

`match (true)` can express ordered conditions and ranges because each arm condition is strictly compared with `true`. Order becomes business logic, so narrower or higher-priority conditions must come first. Directly matching the subject is usually clearer when mapping discrete values.

The nullsafe operator is written `?->`. If its left side is an object, evaluation continues like `->`; if the left side is `null`, the remaining chain short-circuits and the expression becomes `null`. Even argument expressions for the skipped method call aren't evaluated.

Every step that can return `null` needs `?->`. If an ordinary `->` follows and its own left side is later `null`, the access still fails. The nullsafe operator is read-only: it can't appear on the left of an assignment or yield a reference. Updates need an explicit existence check.

## Examples

These four examples share an order-processing setting. They begin with types and branches, then add object modeling, attribute metadata, and nullsafe short-circuiting. Every output shown below comes from running its file with the local PHP 8.3.33 CLI.

### Union types, named arguments, and `match`

The status conversion accepts an internal integer code or an external string reference. Strict comparison in `match` keeps integer `1` separate from string `'1'`, while the named argument explains that the second boolean controls priority.

<!-- quick -->

```php
// file: status_label.php
<?php
declare(strict_types=1);

function statusLabel(int|string $status, bool $priority = false): string
{
    return match ($status) {
        1 => 'queued',
        '1' => 'external-reference',
        2 => $priority ? 'priority' : 'processing',
        default => throw new InvalidArgumentException("unknown status: {$status}"),
    };
}

echo statusLabel(1), PHP_EOL;
echo statusLabel('1'), PHP_EOL;
echo statusLabel(status: 2, priority: true), PHP_EOL;

try {
    echo statusLabel(status: 9), PHP_EOL;
} catch (InvalidArgumentException $error) {
    echo $error::class, ': ', $error->getMessage(), PHP_EOL;
}
```

```text
queued
external-reference
priority
InvalidArgumentException: unknown status: 9
```

<!-- /quick -->

The union says that both technical representations are valid, but the function still rejects unknown statuses. Using `switch` or first converting every input to a string could accidentally merge the integer code and external reference. Tests should cover every type member plus boundary values that look equal but have different types.

`priority: true` is clearer than a bare boolean. The call now depends on the name `$priority`, though. Renaming that parameter requires updating every named call or treating the change as a breaking change to a public API.

### Building objects with property promotion

The order and customer objects use promoted properties to remove duplicate declarations. The constructor body still owns the domain invariant that total must be positive; promotion itself only declares and assigns.

```php
// file: promoted_order.php
<?php
declare(strict_types=1);

final class Address
{
    public function __construct(public string $city) {}
}

final class Customer
{
    public function __construct(
        public string $name,
        public ?Address $address = null,
    ) {}
}

final class Order
{
    public function __construct(
        public string $id,
        public Customer $customer,
        public int|float $total,
    ) {
        if ($this->total <= 0) {
            throw new InvalidArgumentException('total must be positive');
        }
    }

    public function summary(): string
    {
        $city = $this->customer->address?->city ?? 'pickup';
        return sprintf('%s|%s|%.2f', $this->id, $city, $this->total);
    }
}

$delivery = new Order(
    id: 'A-17',
    customer: new Customer('Mina', new Address('Paris')),
    total: 42.5,
);
$pickup = new Order(id: 'A-18', customer: new Customer('Noah'), total: 19);

echo $delivery->summary(), PHP_EOL;
echo $pickup->summary(), PHP_EOL;
```

```text
A-17|Paris|42.50
A-18|pickup|19.00
```

Named arguments keep the second construction readable on one line. The `total` union preserves integer and float input, while `sprintf()` normalizes both to one amount format at the output boundary. Real monetary values also need an explicit precision and currency policy that a union type can't replace.

`address` is nullable, so the step that accesses `city` uses `?->`. The `?? 'pickup'` is a business default separate from the nullsafe operator's propagation semantics. If a missing address should block checkout, reject it in a constructor or service boundary instead of displaying a default.

### Defining and reading repeatable attributes

`RequiresRole` is restricted to methods and may be repeated. The reader first filters the target attributes, then calls `newInstance()` to obtain typed configuration objects.

```php
// file: route_attributes.php
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class RequiresRole
{
    public function __construct(public string $role) {}
}

final class OrderController
{
    #[RequiresRole('support')]
    #[RequiresRole('admin')]
    public function refund(): void {}
}

$method = new ReflectionMethod(OrderController::class, 'refund');
$attributes = $method->getAttributes(RequiresRole::class);

foreach ($attributes as $attribute) {
    $rule = $attribute->newInstance();
    echo $method->getName(), ':', $rule->role, PHP_EOL;
}
```

```text
refund:support
refund:admin
```

The program prints roles because consumer code explicitly reads, instantiates, and interprets the attributes. Without that Reflection logic, `#[RequiresRole]` performs no authorization. Production authorization must also get roles from a trusted identity and define whether repeated attributes mean “any role” or “all roles.”

`IS_REPEATABLE` and `TARGET_METHOD` describe structural constraints, not that composition policy. When adding an attribute consumer, test no attribute, one attribute, repeated attributes, bad arguments, and a bad target rather than only the successful path.

### Observing nullsafe short-circuiting

The last example gives a method argument a visible side effect. When the profile data is absent, `auditLabel()` isn't evaluated. It runs before `format()` only when the object chain exists.

```php
// file: nullsafe_profile.php
<?php
declare(strict_types=1);

final class Profile
{
    public function __construct(public ?DeliveryAddress $address = null) {}
}

final class DeliveryAddress
{
    public function __construct(public string $city) {}

    public function format(string $audit): string
    {
        return "{$audit}:{$this->city}";
    }
}

function auditLabel(): string
{
    echo "audit evaluated\n";
    return 'ship';
}

function destination(?Profile $profile): string
{
    return $profile?->address?->format(auditLabel()) ?? 'pickup';
}

echo destination(new Profile()), PHP_EOL;
echo destination(new Profile(new DeliveryAddress('Paris'))), PHP_EOL;
```

```text
pickup
audit evaluated
ship:Paris
```

Short-circuiting can avoid wasted work, but placing required auditing, counting, or validation in a method argument makes that work conditional. If a side effect must happen whether or not the object exists, run it before the nullsafe chain and use explicit control flow for the missing object.

Both nullable steps use `?->`: `$profile` may be null, and so may `address`. Protecting only the first position and then writing `->address->format()` doesn't protect the second null.

## Pitfalls

### Treating a union type as input validation

> **Pitfall:** `int|string` constrains runtime types only. It doesn't determine whether a string is an allowed status, whether an integer is in range, or what an array contains. A broad union can also spread a data model that should have been normalized across the codebase.

**Fix:** validate raw shape and values at an HTTP, CLI, database, or message boundary, then convert to a narrow internal representation. Test every union member, boundary values, and rejected types. If alternatives have distinct business behavior, consider a value object or enum instead of adding more members.

### Forgetting that named arguments bind parameter names

> **Pitfall:** Generated code often converts every call to named form, then still treats a parameter rename as a risk-free refactor. The old name becomes an `Unknown named parameter` error at runtime, while tests that call only positionally won't expose it.

**Fix:** document stable parameter names on public methods and retain at least one named call in integration tests. Use named arguments when skipping optional parameters or when they materially improve readability. Migrate callers as part of a compatibility change when renaming a public parameter.

### Assuming attributes execute policy

> **Pitfall:** `#[RequiresRole('admin')]` is only metadata. Without a consumer, it checks no identity. A consumer that calls `getAttributes()` but never instantiates the descriptors may also leave bad constructor arguments and invalid targets undiscovered until much later.

**Fix:** identify which startup or request component reads attributes, when it calls `newInstance()`, and how it combines repeats. Use a reflection integration test to walk registered handlers so bad configuration fails during deployment or startup. For security policy, also test the default behavior when metadata is absent.

### Hiding new branches behind `default`

> **Pitfall:** A broad `default` makes future input or an enum case inherit old policy automatically. Omitting `default` has the opposite limitation: it throws `UnhandledMatchError` only when the missing path executes and still provides no compile-time proof.

**Fix:** list every arm for a closed set and have a data provider visit every valid value. Use `default` only when every future value can safely share one fallback. Validate and reject unknown external input before it enters the `match`.

### Letting nullsafe access erase domain meaning

> **Pitfall:** `?->` turns a technically nullable access into `null`, but it doesn't tell whether missing data is expected, retryable, or corrupt. Appending `?? ''` casually further erases the difference between “no object” and “empty field.”

**Fix:** define the domain meaning of each `null`, then choose propagation, a default, or an exception. Test every nullable position in the chain, not only the first. Don't hide required logging, authorization, or validation inside a method argument that may be short-circuited.

### Changing behavior during migration

> **Pitfall:** One commit that promotes constructor properties, replaces `switch` with `match`, and adds unions changes assignment, comparison, and failure modes together. An old `switch` may rely on loose comparison or fall-through, so a direct replacement can be syntactically valid and behaviorally different.

**Fix:** change one semantic dimension at a time, with characterization tests that record old inputs and outputs first. Resolve PHP 8.0 incompatibilities and dependency support before adopting syntax. For every `switch`, inspect subject types, `break`, fall-through paths, and the default arm.

<!-- deep -->

## Feature interaction and compatibility

PHP 8.0 syntax often appears together in one constructor or controller. Combining features doesn't erase their individual boundaries: promoted properties still follow type rules, named arguments still depend on parameter names, attributes on promoted parameters still need a consumer, and a `null` from a nullsafe chain still needs a business decision.

### Argument-binding order

At a call, positional arguments occupy parameters in order before named arguments select unassigned parameters by name. A positional argument after a named one is a syntax error. Naming the same parameter twice or using a nonexistent name produces an `Error`. A parameter's default applies only if no argument bound that parameter.

Array unpacking preserves string keys and treats them as named arguments. That makes `function(...$payload)` convenient-looking but turns an untrusted array directly into a calling protocol. Unknown keys, duplicate bindings, and type errors can all appear at the call site. Project an external payload through an allowlist of known fields before unpacking it.

A variadic parameter collects named arguments that don't match declared parameters and preserves their string keys in the resulting array. That's useful for a deliberately extensible options interface, but it can also hide spelling mistakes. Public APIs should state whether they accept arbitrary named options and reject unknown keys themselves when the set is closed.

### Lazy attribute instantiation

The Reflection API can filter attribute descriptors by class name without first running an attribute constructor. This lets scanners collect metadata cheaply, but it also means a scan-only test doesn't validate configuration. A consumer that needs attribute objects should call `newInstance()` at a controlled boundary and report the exact class, method, and attribute location when construction fails.

Attribute arguments store values representable at compile time rather than arbitrary runtime code. When the object is finally created, its constructor can still validate string formats, numeric ranges, and argument combinations. The target mask constrains where metadata may be attached, the constructor constrains whether its arguments make sense, and the consumer owns how metadata affects behavior.

The PHP language doesn't define how repeated attributes combine. Authorization might accept any listed role, validators might require every rule, and route attributes might register several paths. `IS_REPEATABLE` only permits repetition. The application must still specify ordering, composition, and conflict policy.

### When `match` fails

`match` compares subjects and arm conditions strictly, so numeric strings, booleans, and `null` all need fresh review when replacing an old `switch`. An arm's right side is one expression. Call a named function when the result needs several steps instead of compressing side effects into a dense expression.

Arm conditions are evaluated in order, and only the selected result expression executes. With `match (true)`, reordering conditions can change the result, especially when ranges overlap. Test the value immediately below each boundary, the boundary itself, and the value immediately above it.

`UnhandledMatchError` is on the `Error` branch and can be caught by a `Throwable` boundary, but local code shouldn't turn it into an uninformative default. It usually means the code failed to handle a state that actually arrived. Record the subject and call context, then let the request or job boundary finish under its established failure policy.

### Nullsafe-chain evaluation

A nullsafe chain evaluates left to right. After one step yields `null`, the chain's remaining property accesses, method calls, and corresponding arguments don't occur. A parenthesized independent expression can create its own evaluation region, so don't infer side-effect timing from visual placement alone.

The result of `?->` can't be an assignment target, so `$customer?->address = $address` is invalid. An update needs an explicit owner and failure policy: check `$customer`, then throw, create an object, or return failure if it's absent before performing an ordinary assignment. That explicit branch also makes state changes visible to static analysis and review.

A long chain hides which relationship is allowed to be absent. Beyond two or three steps, assign intermediate results to domain-named variables or expose a model method with a clear result. That lets code distinguish “customer missing,” “address missing,” and “city missing” rather than flattening all of them to one `null`.

## Other PHP 8.0 capabilities

PHP 8.0 also introduced several capabilities to adopt when they match the problem. The table states only well-defined semantics and makes no performance claim without a benchmark environment.

| Capability | Core semantics | Use boundary |
| --- | --- | --- |
| `mixed` type | Explicitly accepts any value, including `null` | Keep at truly open boundaries, not in place of an expressible narrow type |
| `static` return type | Return type follows the runtime called class | Fits inheritance-based fluent APIs that return `$this` |
| `throw` expression | Throws from an arrow function, `??`, ternary, or `match` | Complex failure handling still needs clear statement blocks |
| `str_contains()` and peers | Checks containment, prefix, and suffix | Still needs case, encoding, and empty-string policy |
| `WeakMap` | Object keys don't prevent those objects from collection | Fits attached metadata, not a cache with an owned lifetime |
| JIT | OPcache can compile some execution paths to machine code | Benefit depends on workload and must be measured in your deployment |

`mixed` is the least informative type, not the default with the “best compatibility.” If an internal function handles only `string|array`, writing that union lets callers, the engine, and tools maintain the boundary together. `mixed` is accurate only when a container, serialization entry point, or generic forwarding layer truly accepts any value.

`str_contains($haystack, '')`, `str_starts_with($haystack, '')`, and `str_ends_with($haystack, '')` all return `true`. If a search term comes from a user, the product must decide whether an empty string means “match everything.” The new functions remove confusion between a `strpos()` result and `false`; they don't define domain meaning.

A `WeakMap` key must be an object. An entry can disappear when no other strong reference to its key remains, so a weak map can't be persistent storage or the only copy of data. It fits derived metadata attached to an object without making the cache itself extend that object's lifetime.

JIT is an important PHP 8.0 runtime capability, but web applications often spend time in databases, networks, and serialization instead. Without fixed hardware, configuration, data, and repetitions, no specific speedup is supportable. Establish separate baselines for representative requests and CPU-heavy work before deciding whether the operational complexity is worthwhile.

## Migration verification

Before using the syntax, confirm that the runtime, extensions, Composer dependencies, and deployment images all satisfy the target version. PHP 8 on a developer machine doesn't prove that queue workers, scheduled commands, and production FPM use the same binary and configuration.

The first test pass should keep old code and focus on PHP 8.0 incompatibilities. Important areas include more internal-function argument failures becoming `TypeError`, changed non-strict number-to-string comparisons, error suppression, and selected resources migrating to objects. Use the official migration guide and the APIs your project actually exercises as the exact checklist.

Introduce syntax one feature at a time in a second pass. Whenever replacing a `switch` with `match`, compare original subject types and all fall-through paths. When adding a union, run examples from real boundaries. When adopting named arguments, search callers and inherited overrides of public parameters.

Static analysis can find some type, unreachable-branch, and call problems, but it can't replace runtime reflection and integration tests. Attribute consumers, dynamic calls, framework containers, and serialization boundaries depend on runtime data. Migration evidence should combine static checks, unit tests, and execution through representative entry points.

Put the minimum supported version in `composer.json` and make CI run tests on it, with a second job allowed for the current target. The minimum version catches accidental use of newer syntax; the target version exposes deprecations and forward-compatibility problems. Only when both pass is local syntax success useful deployment evidence.

<!-- /deep -->

[Checkpoint: php/php8-features](https://codewiki.com/php/php8-features/#checkpoint)

## Further reading

- [PHP 8.0 new features](https://www.php.net/manual/en/migration80.new-features.php)
- [PHP type declarations](https://www.php.net/manual/en/language.types.declarations.php#language.types.declarations.union)
- [PHP attributes overview](https://www.php.net/manual/en/language.attributes.overview.php)
- [PHP named arguments](https://www.php.net/manual/en/functions.arguments.php#functions.named-arguments)
- [PHP `match`](https://www.php.net/manual/en/control-structures.match.php)
- [PHP nullsafe operator](https://www.php.net/manual/en/language.oop5.basic.php#language.oop5.basic.nullsafe)
