The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Union Types Migration Guide for PHP 8.0+


In anything at all, perfection is finally attained not when there is no longer anything to add, but when there is no longer anything to take away… — Antoine de Saint-Exupéry

This quote about design simplicity applies directly to PHP’s type system. For years, PHP developers expressed type constraints through documentation—doc-comments that indicated expectations but provided no runtime enforcement. The language itself lacked the expressiveness to declare that a parameter could accept multiple types.

That changed with PHP 8.0’s introduction of Union Types. Now, you can write int|float $value directly in the function signature, where PHP itself will enforce it. This seemingly small addition represents a significant step forward in PHP’s evolution toward stronger type safety—while still respecting PHP’s historical flexibility.

In this guide, we’ll walk through migrating your existing PHP codebase to use Union Types effectively. We’ll focus on practical, real-world migration patterns, tools that can help automate the process, and the trade-offs to consider along the way. By the end, you’ll understand not only how to use Union Types, but when they’re the right tool for the job—and when a different approach might serve you better.

What are Union Types?

When you’re working with PHP functions, you’ll often encounter situations where a parameter might reasonably accept more than one type. For example, a function that processes a user-provided identifier might need to handle both integer database IDs and string UUIDs. Or a function that works with numeric data from various sources might need to accept integers, floats, or even numeric strings.

Union Types give you a way to express this flexibility directly in your type declarations. Instead of relying solely on doc-comments to communicate that a parameter can be multiple types, you can now use the pipe symbol (|) to declare a union type right in the function signature where PHP itself will enforce it.

Note: If you’re coming from PHP 7.x or earlier, this might feel like a significant shift. We’ll walk through it step by step. Don’t worry—the syntax is straightforward, and we’ll show you plenty of examples.

Let’s look at a concrete example from a typical PHP application: a function that normalizes a user-provided quantity, which might come from a form submission (string), an API payload (int or float), or a database query result.

/**
 * Normalize a quantity value to a float.
 *
 * @param int|float|string $quantity The quantity to normalize
 * @return float
 */
function normalizeQuantity($quantity): float
{
    return (float) $quantity;
}

Before Union Types, the only way to communicate that $quantity could be multiple types was through that doc-comment—and PHP would happily accept an array or object if you passed one, leading to runtime errors or unexpected type juggling. With Union Types, we can make our intent explicit and enforceable:

function normalizeQuantity(int|float|string $quantity): float
{
    return (float) $quantity;
}

Now, when someone calls this function, PHP will check at runtime that the argument matches one of the allowed types (int, float, or string). If they pass an array, for instance, PHP throws a clear TypeError immediately. Of course, the function still needs to handle the conversion logic appropriately—but those type mismatches are caught much earlier.

This isn’t just about catching errors, though; it’s about making your code’s expectations unambiguous to both PHP and other developers who will work with it. When someone sees that function signature, they know exactly what types are acceptable without needing to parse through documentation.

Benefits of Using Union Types

When you start using Union Types in your PHP projects, you’ll quickly notice several practical benefits that make your development process smoother and your code more reliable. In our experience, teams that adopt Union Types typically find they spend less time debugging type-related issues and more time building features. Let’s examine the key advantages.

Improved Type Safety: By explicitly declaring the allowed types in your function signatures, you catch type-related errors at runtime before they cause unexpected behavior in your applications—though, of course, you should still write tests to cover your logic. For example, if you’ve declared that a function accepts only int|float and someone accidentally passes a string, PHP will throw a clear TypeError immediately.

Enhanced Code Clarity: Union Types communicate your code’s expectations directly in the signature. When you see function processValue(int|string $value): bool, you immediately understand what types are acceptable without needing to parse through documentation blocks. This makes code reviews faster and onboarding new team members smoother.

Reduced Documentation Overhead: Of course, you still need doc-comments for explaining complex logic or edge cases—but Union Types eliminate the need to redundantly specify types in both the signature and the doc-comment. When you do change a type, you only need to update it in one place—the signature—rather than hunting through multiple doc-blocks to keep them synchronized. We’ve found this alone saves significant maintenance effort over time.

Better Tooling Support: Static analysis tools like PHPStan and Psalm can provide significantly more accurate feedback when your code uses proper type declarations. These tools can detect potential type mismatches before you even run your code, saving you hours of debugging time later. Many teams report that adopting Union Types reduces false positives in their static analysis, making those tools more trustworthy.

These benefits compound over time—especially in larger codebases where multiple developers are working together. When everyone can quickly understand what types a function expects, you spend less time deciphering interfaces and more time building features.

Tip: If you’re using PHPStan, we’ll show you later how it can help identify good candidates for Union Types in section 4.

Of course, there are times when Union Types might not be the best solution—for example, when you’re dealing with complex domain objects where polymorphism would be more appropriate. But for many everyday cases, particularly those involving scalar types or simple arrays, Union Types provide an excellent balance of expressiveness and maintainability. We’ll explore when not to use them later in this guide.

Migration Strategy: A Systematic Approach

When you’re looking to adopt Union Types in an existing codebase, approaching the migration systematically will save you time and prevent errors. You don’t need to convert everything at once—instead, identify the areas where Union Types will provide the most immediate benefit and work through them incrementally. In our experience working with teams of various sizes, the following phased approach tends to work well.

Though the exact approach will depend on your codebase’s specific characteristics, we’ve found this strategy effective across projects ranging from small applications to large enterprise systems. Let’s break it down into four phases.

Phase 1: Identify Opportunities

Start by finding the low-hanging fruit: code that already documents multiple types in doc-comments. These locations already acknowledge that multiple types are valid, so converting them to Union Types is typically straightforward and low-risk. After all, if the documentation already says int|float, you’re just moving that information to where PHP can enforce it.

Specifically, look for:

  • Functions with @param or @return tags that list multiple types (like int|float or string|null)
  • Class properties annotated with @var that indicate multiple possible types
  • Variables that are frequently checked with is_int(), is_string(), or similar type-checking functions

One may wonder: how do you actually find these patterns in a large codebase? One practical approach is to use grep. For example, you could search for @param lines containing a pipe character:

$ grep -r "@param.*|" app/src/

This will show you every function parameter that’s already documented as accepting multiple types. Of course, this only catches doc-comments—you’ll also want to look at where you’re using type-checking functions like is_int() in your code, as those often indicate places a union type could clarify intent.

Let’s look at a typical example you might encounter:

/**
 * Calculate tax on a price.
 *
 * @param int|float $price The pre-tax price (can be cents as int or dollars as float)
 * @return int|float The tax amount in the same type as the input
 */
function calculateTax($price) {
    return $price * 0.08;
}

This is a clear candidate for conversion to Union Types—the doc-comment already tells us multiple types are valid, and the function is straightforward enough that we can trust the type declaration won’t break existing usage patterns.

Phase 2: Refactor Methodically — A Walkthrough

Safety First: Before you begin any automated refactoring, ensure your version control system has a clean working state. This migration involves changing many files, and being able to revert is essential.

Once you’ve identified candidates, convert them one by one. The key is to make small, focused changes that improve type safety without altering the function’s behavior. Let’s walk through a practical migration of a small utility class to see how this works in practice.

Suppose you have a PriceCalculator class in your codebase that looks like this:

<?php

/**
 * Calculate and format prices.
 */
class PriceCalculator
{
    /**
     * Calculate the tax amount.
     *
     * @param int|float $price The pre-tax price
     * @param float $taxRate The tax rate as decimal (e.g., 0.08 for 8%)
     * @return int|float The tax amount
     */
    public function calculateTax($price, $taxRate = 0.08)
    {
        return $price * $taxRate;
    }
    
    /**
     * Format a price for display.
     *
     * @param int|float $price The price to format
     * @param string|null $currency The currency symbol (null for none)
     * @return string The formatted price
     */
    public function formatPrice($price, $currency = null)
    {
        if ($currency) {
            return $currency . number_format($price, 2);
        }
        return number_format($price, 2);
    }
    
    /**
     * Get the price including tax.
     *
     * @param int|float $price The pre-tax price
     * @return int|float The total price including tax
     */
    public function getPriceWithTax($price)
    {
        return $price + $this->calculateTax($price);
    }
}

You can see this class relies heavily on doc-comments to indicate that the $price parameter can be either int or float. Though the implementation works, those doc-comments provide no runtime protection—someone could call calculateTax() with a string, for example, and PHP would only fail at some point inside the function or, worse, produce a nonsensical result through type juggling.

Let’s migrate this class to use Union Types step by step.

Step 1: Add strict_types declaration

First, ensure each file has declare(strict_types=1); at the top. This is important because without strict types, PHP will still perform some implicit type coercion even with Union Types—though it’s more limited than without any types. With strict types enabled, passing a string to int|float will throw an error immediately rather than attempting to convert it.

Why this matters: In our experience, enabling strict_types catches subtle bugs that might otherwise slip through. Of course, you should test thoroughly after adding this, as it may reveal existing type coercion your code was unintentionally relying on.

<?php

declare(strict_types=1);

/**
 * Calculate and format prices.
 */
class PriceCalculator
{
    // ... rest of class
}

Step 2: Convert function signatures

Now convert each method’s doc-comment types to actual type declarations. Start with the simplest method to build confidence:

public function calculateTax(int|float $price, float $taxRate = 0.08): int|float
{
    return $price * $taxRate;
}

Notice we removed the $taxRate doc-comment too—it was never a union type, but since we’re here we can add its type declaration as well. (Before union types, you’d often leave simple scalar types in doc-comments to keep consistency; now you should put all parameter and return types in the signature.)

Next, convert formatPrice():

public function formatPrice(int|float $price, ?string $currency = null): string
{
    if ($currency) {
        return $currency . number_format($price, 2);
    }
    return number_format($price, 2);
}

Here we’ve expressed the nullable $currency parameter using ?string syntax, which is equivalent to string|null. We could also write string|null $currency if we prefer—both are valid Union Types. The ?string notation is shorter, though some developers find string|null more explicit when it’s part of a larger union.

And finally getPriceWithTax():

public function getPriceWithTax(int|float $price): int|float
{
    return $price + $this->calculateTax($price);
}

Step 3: Remove redundant doc-comments

After converting to Union Types, the type information in your doc-comments becomes redundant—in fact, if you keep it, you need to ensure it matches the signature or your static analysis tools will complain. You should remove the @param and @return tags that are now covered by the signature, though you may keep other doc-comment sections that explain parameters’ meanings, edge cases, or examples.

Your final migrated class might look like this:

<?php

declare(strict_types=1);

/**
 * Calculate and format prices.
 */
class PriceCalculator
{
    /**
     * Calculate the tax amount.
     *
     * @param int|float $price The pre-tax price (can be cents as int or dollars as float)
     * @param float $taxRate The tax rate as decimal (e.g., 0.08 for 8%)
     */
    public function calculateTax(int|float $price, float $taxRate = 0.08): int|float
    {
        return $price * $taxRate;
    }
    
    /**
     * Format a price for display.
     *
     * @param int|float $price The price to format
     * @param string|null $currency The currency symbol (null for none)
     */
    public function formatPrice(int|float $price, ?string $currency = null): string
    {
        if ($currency) {
            return $currency . number_format($price, 2);
        }
        return number_format($price, 2);
    }
    
    /**
     * Get the price including tax.
     *
     * @param int|float $price The pre-tax price
     * @return int|float The total price including tax
     */
    public function getPriceWithTax(int|float $price): int|float
    {
        return $price + $this->calculateTax($price);
    }
}

Now let’s verify this works as expected. Create a simple test script:

<?php

require_once 'PriceCalculator.php';

$calculator = new PriceCalculator();

// These should all work:
echo $calculator->calculateTax(100) . "\n";        // int in, float out (or int)
echo $calculator->calculateTax(100.50) . "\n";     // float in, float out
echo $calculator->formatPrice(1234.56, '$') . "\n"; // formatted with currency
echo $calculator->getPriceWithTax(200) . "\n";     // chained call

// This should throw a TypeError:
try {
    $calculator->calculateTax("100");
} catch (TypeError $e) {
    echo "Got expected TypeError: " . $e->getMessage() . "\n";
}

When you run this, you should see the tax calculations work correctly and the string input produces an error like:

Got expected TypeError: PriceCalculator::calculateTax(): Argument #1 ($price) must be of type int|float, string given

That’s exactly what we want—PHP is now enforcing the type constraints at runtime.

3. Handle Edge Cases and Special Situations

As you work through your migration, you’ll encounter a few patterns that require special attention. Let’s go through the most common ones.

Nullable Types: In PHP 8.0+, you can include null directly in Union Types. A parameter that’s either a string or null can be declared as string|null $param. You could also use the nullable shorthand ?string, which is equivalent—though note that ?string can only be used alone, not as part of a larger union like int|?string (which would be invalid). So if you need null plus other types, use Something|null explicitly.

For example, consider a function that retrieves a user by ID, which might return a User object or null if not found:

// Before
/**
 * @param int $id
 * @return User|null
 */
function findUser($id) {
    // ...
}

// After
function findUser(int $id): ?User  // or: User|null
{
    // ...
}

The Special Case of false: While false is a valid type that can be included in a union (like array|false), true is not allowed as a standalone type. If you need to represent both boolean values, use bool instead of true|false. For example, a function that returns success or failure should return bool, not true|false.

// Don't do this:
function isValid(): true|false {} // Invalid - 'true' is not a valid type

// Do this instead:
function isValid(): bool {
    return true;  // or false
}

Type Casting Nuances: PHP’s type system, even with Union Types, still performs some implicit coercion when strict_types is disabled (which is the default). With declare(strict_types=1), passing a string to an int|float parameter will throw an error. Without strict types, PHP might quietly convert "42" to 42—but this behavior can be surprising.

Of course, you should enable strict_types for any codebase that can support it (PHP 7.0+). If you’re working with a codebase that can’t use strict types yet, be aware that some implicit conversions will still happen. For example, with lax typing, "42" passes as int|float, but "hello" throws a TypeError because it can’t be coerced to a number.

Avoiding Overly Complex Unions: As a general guideline, if you find yourself writing unions with three or more types (like int|string|array|bool|null), it’s worth questioning whether that function or property is trying to do too much. This isn’t a hard rule—sometimes complex unions are genuinely appropriate—but when the number of allowed types grows large, it often signals that polymorphism might serve you better. For instance, instead of a function that accepts File|string|array|Resource representing various ways to provide data, consider creating distinct, more focused functions or using an interface that all those types can implement.

4. Use Tooling to Accelerate the Migration

For small codebases, you might convert Union Types manually. For larger projects with hundreds or thousands of doc-comments, automated tools can help. Of course, you should never run automated refactoring without reading the changes—these tools are helpers, not replacements for your judgment.

PHPStan (version 1.0+): This static analysis tool can suggest places where Union Types would be appropriate based on how you use variables. When you run PHPStan at level 8 or higher, it will report “Missing type” and “Mixed assignment” issues that often point to union candidates. Though PHPStan won’t automatically rewrite your code, it can generate a list of functions and properties that would benefit from explicit types.

Rector: This tool actually performs automated refactoring. The UnionTypeRector rule converts doc-block type annotations to native Union Types. You can configure Rector to target specific directories and set a minimum PHP version (say, 8.0). When you run it, Rector will rewrite your code in place—so be sure to have your version control system ready. You can review what changed with git diff and adjust as needed.

Here’s an example Rector configuration snippet:

// rector.php
use Rector\Php80\Rector\ClassMethod\AddUnionTypeRector;
use Rector\Php80\Rector\Property\AddUnionTypeRector;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $c) {
    $services = $c->services();
    $services->set(AddUnionTypeRector::class);
    $services->set(AddUnionTypeRector::class)->call('configure', [[
        'php_version' => 8.0,
    ]]);
};

Run it with:

$ rector process app/src --dry-run

The --dry-run flag shows what would change without actually modifying files. Once you’re satisfied, omit it to apply the changes.

Important caveat: Automated tools may not perfectly handle all edge cases—for example, they might miss @var annotations in complex doc-comments or mis-handle nullable types. Always review the changes carefully. In particular, check that the union order makes sense (see the next section on ordering).

What About PHP 8.1 and 8.2 Features?

Though this guide focuses on Union Types introduced in PHP 8.0, it’s worth noting that PHP 8.1 added Intersection Types (using the & operator), and PHP 8.2 added true, false, and null as standalone types. If you’re running PHP 8.1+, you can use Intersection Types to require that a value satisfies multiple interfaces simultaneously—for example, Countable&Traversable. PHP 8.2 further allows the literal types true and false (e.g., a function that always returns true can declare : true), though these are specialized use cases.

For migration to Union Types specifically, PHP 8.0+ is required. If your production environment runs PHP 8.0 or higher, you can safely use Union Types. If you’re still on PHP 7.x, you’ll need to complete your PHP version upgrade first.

The Big Picture: When You Might NOT Use Union Types

Though Union Types are powerful, they aren’t always the right solution. As mentioned earlier, if you find yourself needing a union with many types, polymorphism through interfaces or abstract classes might be cleaner. Also, if you’re writing library code that needs to support PHP versions earlier than 8.0, you can’t use native Union Types—though you can keep the doc-comments for static analysis alongside your code. Many libraries adopt a strategy of using Union Types when available and otherwise falling back to doc-comments.

The key is to view Union Types as one tool in your type-safety toolbox, not a universal replacement for good design. When used thoughtfully, they improve code clarity and prevent bugs; when overused or misapplied, they can make signatures cumbersome.

Now, let’s move on to some common pitfalls and how to avoid them.

Common Pitfalls and How to Avoid Them

As you work with Union Types, you’ll encounter a few situations where your initial instincts might lead you astray. Being aware of these common pitfalls will help you use this feature effectively without overcomplicating your code.

When Union Types Signal a Design Issue: Sometimes, reaching for a Union Type is actually telling you that your design could be improved. If you find yourself writing unions with three or more types (like int|string|array|bool|null), you should ask whether that function or property is trying to do too much. There are certainly legitimate cases for multiple types—for instance, int|float for numeric values or string|null for optional strings—but when a union grows large, it often suggests you might benefit from creating more specific functions or using polymorphism.

Let’s look at a concrete example. Suppose you have a function that processes data fetched from various sources, and you write:

function processData(int|float|string|array|bool $data): mixed
{
    // handle all these types somehow
}

That’s a strong signal that this function has too many responsibilities. You might instead create separate functions: processInteger(), processFloat(), processArray(), etc., or define an interface that different data types can implement. Of course, if you’re truly dealing with a legitimate mixed-type input (like configuration values that can be many scalar types), a union may be appropriate—but large unions warrant extra scrutiny.

The Intersection Types Consideration: PHP 8.1 introduced Intersection Types, which require that a value satisfies multiple constraints simultaneously (like Countable&Traversable). While you can mix Union and Intersection Types, doing so in complex ways can make type signatures difficult to read. For example, (int|string)&Countable is valid but challenging to parse mentally. When you find yourself needing such complex expressions, it’s often a sign to revisit your design—perhaps the value should implement a single interface that captures all required behaviors, or you could refactor to separate concerns.

Keeping Doc-comments in Sync: If you maintain doc-comments alongside your Union Types—which is perfectly reasonable for explaining parameter meanings, edge cases, or examples—be absolutely certain they don’t contradict the signature. There’s nothing more confusing than seeing function process(int|string $value) while the doc-comment says “Only accepts integers.” When you update a signature, take a moment to review whether the doc-comment still adds value or has become redundant. Many teams adopt a convention: if a parameter has a Union Type in the signature, omit the @param type from the doc-comment (keep other documentation if needed). This avoids the synchronization problem entirely.

Union Order and Readability: PHP doesn’t care about the order of types in a union—int|float is the same as float|int. However, for human readers, a consistent ordering improves scanning. Many teams follow the convention: null last (e.g., string|null, not null|string), primitive types in a predictable order (often bool, int, float, string), then class/interface names. Though this is a matter of style, establishing a team convention helps keep code consistent.

Falsy Values and false: Remember that false is a valid union member, but true is not allowed. If you need to represent both true and false, use bool. Also, be aware that with strict_types=1, passing values that coerce to false (like empty strings or zero) will NOT match false in a type check—they’ll cause a TypeError if they don’t match another union member. This is a common source of confusion. For example:

function mayReturnFalse(): bool|string {
    // ...
}

$result = mayReturnFalse();
if ($result === false) {  // This is fine
    // handle false case
}

But what if someone passes an empty string to a parameter of type bool|string? With strict types, "" matches string, so that’s fine. However, if your function internally checks if ($value) and treats empty strings as “falsey,” that’s fine—just don’t confuse type emptiness with the false literal. Understanding this distinction will help you avoid bugs.

These pitfalls aren’t reasons to avoid Union Types—they’re factors to keep in mind as you apply this feature thoughtfully in your codebase. The goal is clearer, safer code, not more complex type gymnastics.

Real-World Examples

Here are practical examples from actual PHP applications where Union Types improve code clarity and safety.

Accepting Multiple Input Formats

In web applications, you often need to accept IDs that might come from different sources—URL parameters (strings), database results (integers), or UUIDs (strings with special format). A repository pattern might handle this elegantly:

<?php

declare(strict_types=1);

class ProductRepository
{
    /**
     * Find a product by its identifier.
     *
     * @param int|string $id Either numeric ID or UUID string
     * @return Product|null The product if found, null otherwise
     */
    public function find(int|string $id): ?Product
    {
        if (is_int($id)) {
            return $this->findById($id);
        }
        
        // UUID format check
        if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $id)) {
            return $this->findByUuid($id);
        }
        
        throw new InvalidArgumentException("Invalid product identifier: $id");
    }
    
    private function findById(int $id): ?Product { /* ... */ }
    private function findByUuid(string $uuid): ?Product { /* ... */ }
}

Notice how the Union Type int|string clearly communicates that both ID formats are valid, while the internal logic can branch appropriately. Without this, you’d need a doc-comment that could be ignored, or you’d resort to mixed which loses all type information.

Model Properties with Optional Values

Many ORM entities have fields that may be null in the database. Union Types handle this cleanly:

<?php

declare(strict_types=1);

class Order
{
    // Shipped date is null until order ships
    private ?DateTimeImmutable $shippedAt = null;
    
    // Tracking number may be present or absent
    private string|null $trackingNumber = null;
    
    // Discount can be a percentage (float) or fixed amount (int)
    private int|float|null $discount = null;
    
    public function setShippingDate(?DateTimeImmutable $date): void
    {
        $this->shippedAt = $date;
    }
    
    public function getTotalWithDiscount(float $baseTotal): float
    {
        if ($this->discount === null) {
            return $baseTotal;
        }
        
        if (is_int($this->discount)) {
            return $baseTotal - $this->discount;
        }
        
        return $baseTotal * (1 - $this->discount);
    }
}

The nullable union ?DateTimeImmutable is equivalent to DateTimeImmutable|null, and some developers prefer the shorter form for simple nullable types. Your team should establish consistent conventions.

Variadic Functions with Multiple Accepted Types

Sometimes a function should accept several arguments, each of which could be one of multiple types. For example, a logging function that accepts either strings or Throwable objects:

<?php

declare(strict_types=1);

class Logger
{
    /**
     * Log one or more messages or exceptions.
     *
     * @param array<int, string|Throwable> $messages
     */
    public function log(array $messages): void
    {
        foreach ($messages as $message) {
            if ($message instanceof Throwable) {
                $this->logException($message);
            } else {
                $this->logString((string) $message);
            }
        }
    }
    
    private function logException(Throwable $e): void { /* ... */ }
    private function logString(string $msg): void { /* ... */ }
}

Note the use of a Union Type in the parameter’s array definition: array $messages could be more precisely typed as array<int, string|Throwable> if your PHP version supports it (PHP 8.0+ for array shape syntax with Union Types). This tells both PHP and future maintainers that the array contains either strings or exceptions—and no other types.

Configuration Arrays with Mixed Value Types

Configuration systems frequently accept arrays where values can be strings, integers, booleans, or nested arrays. This is a case where a union might span several types:

<?php

declare(strict_types=1);

class Config
{
    /**
     * Load configuration from an array.
     *
     * @param array<string, string|int|bool|array> $values
     * @return void
     */
    public function load(array $values): void
    {
        foreach ($values as $key => $value) {
            if (is_array($value)) {
                $this->loadNested($key, $value);
            } else {
                $this->set($key, $value);
            }
        }
    }
}

Here, string|int|bool|array represents the allowed scalar types plus nested configuration arrays. Of course, if your configuration grows more complex, you might consider creating a ConfigValue class with explicit types instead—but for many applications, this union strikes a good balance.

API Responses That Might Fail

When building HTTP APIs, you often have handlers that return either a success response or an error. Instead of returning mixed or using exceptions for control flow, a union can model this explicitly:

<?php

declare(strict_types=1);

namespace App\Http;

class ApiResponse
{
    // ...
}

class ErrorResponse
{
    public function __construct(
        public string $message,
        public int $code
    ) {}
}

/**
 * Handler for user creation endpoint.
 */
class CreateUserHandler
{
    /**
     * @param CreateUserRequest $request
     * @return ApiResponse|ErrorResponse
     */
    public function handle(CreateUserRequest $request): ApiResponse|ErrorResponse
    {
        $user = $this->createUser($request);
        
        if ($user->wasCreated()) {
            return new ApiResponse(['user_id' => $user->id], 201);
        }
        
        return new ErrorResponse('User already exists', 409);
    }
}

The return type ApiResponse|ErrorResponse makes it clear that callers must handle both cases. This is more maintainable than returning mixed and more structured than using exceptions for expected error conditions.

These examples illustrate how Union Types can make your PHP code’s intent clearer, safer, and easier to maintain. The key is to use them where they genuinely communicate permitted types—not to force unions where a different design would serve better.

Conclusion

Union Types, introduced in PHP 8.0, provide a practical way to express that a parameter, return value, or property may be one of several types. As we’ve seen, migrating from doc-comment type hints to native Union Types can improve type safety, reduce documentation drift, and make your code’s intent clearer to both PHP and your team.

The migration process, while straightforward in many cases, does require careful review—especially around edge cases, nullable types, and ensuring that your type unions genuinely represent the intended set of acceptable values. Tools like PHPStan and Rector can accelerate the work, but they’re no substitute for understanding the implications of each change.

Whether you’re maintaining a small application or a large codebase, consider approaching Union Types incrementally: start with high-value candidates, verify your changes through tests, and evaluate whether a union or a different design (like an interface) best serves each situation.

If you’d like to dive deeper into PHP’s type system, the official PHP documentation on Union Types provides full reference material. You might also explore related features like intersection types (PHP 8.1+) and the true/false/null explicit types (PHP 8.2+) as your project evolves.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming