PHP 8.0 features

Use union types, named arguments, attributes, match, constructor property promotion, and the nullsafe operator without hiding migration risks.

level intermediate time 13 min at Standard depth
version PHP 8.3.33
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.

CapabilityMain timingLanguage guaranteeGuarantee it does not provide
Union typeCall, return, property assignmentValue satisfies at least one memberExternal input passed domain validation
Named argumentCall bindingName selects the target parameterParameter names may change freely
AttributeStored after compilation, read through reflectionReflection API can discover metadataMetadata automatically causes behavior
matchExpression evaluationStrict comparison, one branch, a resultCompile-time exhaustiveness proof
Property promotionObject constructionDeclares a property and assigns the argumentAutomatic domain-invariant validation
Nullsafe operatorExpression evaluationShort-circuits to null on nullMissing 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.

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;
}
queued
external-reference
priority
InvalidArgumentException: unknown status: 9

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.

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

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;
}
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.

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;
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

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

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

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

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

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

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

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.

CapabilityCore semanticsUse boundary
mixed typeExplicitly accepts any value, including nullKeep at truly open boundaries, not in place of an expressible narrow type
static return typeReturn type follows the runtime called classFits inheritance-based fluent APIs that return $this
throw expressionThrows from an arrow function, ??, ternary, or matchComplex failure handling still needs clear statement blocks
str_contains() and peersChecks containment, prefix, and suffixStill needs case, encoding, and empty-string policy
WeakMapObject keys don’t prevent those objects from collectionFits attached metadata, not a cache with an owned lifetime
JITOPcache can compile some execution paths to machine codeBenefit 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.

Further reading

checkpoint

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

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