advanced php language
Union, Intersection, and DNF Types
PHP types are not only documentation. They are executable rules that stop the wrong kind of value entering a function, being stored on an object, or being returned from an API.
Union, intersection, and DNF types let you describe more realistic shapes than a single class or scalar. They are useful when a value may be one of a few valid forms, or when an object must satisfy more than one contract at the same time.
Union Types
A union type means a value may be any one of the listed types.
<?php
declare(strict_types=1);
function formatIdentifier(int|string $id): string
{
return 'ID-' . (string) $id;
}
echo formatIdentifier(42) . PHP_EOL;
echo formatIdentifier('A100') . PHP_EOL;
// Prints:
// ID-42
// ID-A100
This is better than accepting mixed when the real rule is narrower. The function does not accept arrays, booleans, objects, or null; it accepts only the two forms that make sense.
Unions are common at application boundaries. A route parameter may arrive as a string, while older internal code may already pass an integer. A repository method may return an object when it finds a record, or null when it does not.
<?php
declare(strict_types=1);
final class User
{
public function __construct(
public int $id,
public string $email,
) {
}
}
function findUser(int $id): User|null
{
if ($id !== 7) {
return null;
}
return new User(7, 'ada@example.com');
}
$user = findUser(7);
echo $user?->email ?? 'not found';
echo PHP_EOL;
// Prints:
// ada@example.com
User|null is the long form of ?User. Use whichever is clearer for the surrounding code, but do not write both together.
The false Return Type
Some older PHP APIs return a useful value or false on failure. PHP allows that shape to be expressed directly.
<?php
declare(strict_types=1);
function readConfigLine(string $path): string|false
{
if (!is_file($path)) {
return false;
}
$line = fgets(fopen($path, 'rb'));
return $line === false ? false : trim($line);
}
$line = readConfigLine('/path/that/does-not-exist');
echo $line === false ? 'missing' : $line;
echo PHP_EOL;
// Prints:
// missing
For new domain code, prefer null, an exception, or a result object when that communicates failure more clearly. Use false mainly when wrapping or matching older PHP behaviour.
Intersection Types
An intersection type means a value must satisfy every listed type.
<?php
declare(strict_types=1);
interface CanRender
{
public function render(): string;
}
interface CanCache
{
public function cacheKey(): string;
}
final class ProductCard implements CanRender, CanCache
{
public function render(): string
{
return '<article>Keyboard</article>';
}
public function cacheKey(): string
{
return 'product-card:keyboard';
}
}
function cacheRendered(CanRender&CanCache $component): string
{
return $component->cacheKey() . ' => ' . $component->render();
}
echo cacheRendered(new ProductCard()) . PHP_EOL;
// Prints:
// product-card:keyboard => <article>Keyboard</article>
CanRender&CanCache says the function needs both behaviours. A class that can render but cannot provide a cache key is not enough.
Intersection types are useful around framework extensions, decorators, exporters, and service objects where the code needs a combination of contracts without caring about the concrete class name.
DNF Types
DNF stands for disjunctive normal form. In PHP, it lets you combine unions and intersections in a controlled way by grouping intersections inside a union.
<?php
declare(strict_types=1);
interface CanExport
{
public function export(): string;
}
interface HasFilename
{
public function filename(): string;
}
final class CsvReport implements CanExport, HasFilename
{
public function export(): string
{
return 'id,total';
}
public function filename(): string
{
return 'orders.csv';
}
}
final class InlineReport
{
public function __construct(
public string $body,
) {
}
}
function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
if ($report instanceof InlineReport) {
return 'inline: ' . $report->body;
}
return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}
echo sendReport(new CsvReport()) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;
// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today
The type means: accept either an object that is both CanExport and HasFilename, or accept an InlineReport. The parentheses matter because they show which contracts belong together.
DNF types are less common than simple unions and intersections. When they appear, they should describe a real API boundary, not a clever puzzle.
Types Do Not Replace Validation
Type declarations answer "what kind of value is this?" They do not answer every business rule.
<?php
declare(strict_types=1);
function applyDiscount(int|float $percentage): string
{
if ($percentage < 0 || $percentage > 100) {
throw new InvalidArgumentException('Discount must be between 0 and 100.');
}
return 'Discount: ' . $percentage . '%';
}
echo applyDiscount(15) . PHP_EOL;
// Prints:
// Discount: 15%
int|float prevents strings and arrays. The range check still belongs inside the function because 500 is a valid integer but not a valid discount.
Choosing The Right Type
Use a single concrete type when there is only one valid shape. Use a union when there are a few genuine alternatives. Use an intersection when the code needs several behaviours on the same object. Use a DNF type only when the allowed combinations would otherwise be unclear.
Avoid using broad types to hide design problems. If a function accepts string|int|array|object|null, it is probably doing too much or sitting at a boundary that needs parsing into clearer application objects.
For junior PHP work, the important skill is not memorising the terminology. It is being able to read a signature, know what values are allowed, and avoid weakening that signature when fixing a bug.
Practice
Practice: Model Allowed Report Inputs
Create a small report-sending example that uses union and intersection types deliberately.
Task
Build:
- a
CanExportinterface with anexport(): stringmethod - a
HasFilenameinterface with afilename(): stringmethod - a
CsvReportclass that implements both interfaces - an
InlineReportclass that carries a body string - a
sendReport()function that accepts(CanExport&HasFilename)|InlineReport
Use strict types. Show one attachment-style report and one inline report. Keep the expected output inside the PHP code block as printed lines or comments.
Afterward, add a short note explaining why this is better than accepting mixed.
Show solution
This solution uses an intersection for reports that must be exportable and named, then a union to allow a separate inline report shape.
<?php
declare(strict_types=1);
interface CanExport
{
public function export(): string;
}
interface HasFilename
{
public function filename(): string;
}
final class CsvReport implements CanExport, HasFilename
{
public function __construct(
private string $filename,
private string $contents,
) {
}
public function export(): string
{
return $this->contents;
}
public function filename(): string
{
return $this->filename;
}
}
final class InlineReport
{
public function __construct(
public string $body,
) {
}
}
function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
if ($report instanceof InlineReport) {
return 'inline: ' . $report->body;
}
return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}
echo sendReport(new CsvReport('orders.csv', 'id,total')) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;
// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today
This is better than mixed because the function signature tells the next developer exactly which shapes are valid. PHP will reject unrelated values before the function tries to use methods that may not exist.