PHP / Advanced: reliable models and runtime evidence / Lesson 13
Advanced types and explicit domain models
Use union, intersection, enum, and readonly features within declared version boundaries. Explain what PHP checks natively and what documentation or static analysis adds without inventing native generics.
On this page
Lesson goals
- Model alternatives and capabilities with appropriate types.
- Use enums for bounded state sets.
- Separate runtime declarations from analyzer-only generic notation.
Type syntax has version and semantic limits
Union types arrived in PHP 8.0, intersection types in PHP 8.1, and disjunctive normal form combinations in PHP 8.2. The course’s PHP 8.3 syntax baseline can use these features, but the examples were executed only on the installed PHP 8.5.12 CLI. A union expresses alternatives; an intersection requires capabilities simultaneously and is not a list of allowed scalar values. Types reduce ambiguity but do not validate a complete request schema, permission, or numeric range. Avoid a broad mixed return when a small structured success/failure contract would explain the domain more clearly.
Enums make a closed state set explicit
Enums, introduced in PHP 8.1, provide named cases and may have backing scalar values. A backed enum can decode a known external representation with tryFrom, returning null for an unknown backing value, or use from when failure should throw. This is a useful boundary for a defined state vocabulary, not automatic authorization for transitions between those states. A canceled enrollment must still reject confirmation if the business rule forbids it. Keep external strings separate from internal cases so misspellings are rejected deliberately rather than silently accepted as another state.
<?php
declare(strict_types=1);
enum EnrollmentState: string { case Pending = "pending"; case Confirmed = "confirmed"; }
$state = EnrollmentState::tryFrom("confirmed");
echo $state?->name ?? "unknown", PHP_EOL;
echo EnrollmentState::tryFrom("other") === null ? "rejected
" : "accepted
";
Confirmed
rejected
Known text maps to a case and unknown text remains invalid. Transition permission is a separate rule.
Intersections express required capabilities
An interface intersection can express that an object must satisfy two capabilities. This can be useful at a narrow boundary, but it should not replace a coherent domain interface merely to combine unrelated responsibilities. The example uses Countable and IteratorAggregate with an ordinary array-backed collection. The collection returns a fresh iterator for each traversal, preserving repeatability. Native return declarations constrain the method contract; they do not enforce the element type of every item yielded. Validate elements in construction or use explicit domain collection operations when the API promises a particular item type.
<?php
declare(strict_types=1);
final class Titles implements Countable, IteratorAggregate {
public function __construct(private array $items) {}
public function count(): int { return count($this->items); }
public function getIterator(): Traversable { return new ArrayIterator($this->items); }
}
function describe(Countable&IteratorAggregate $items): string {
return count($items) . ":" . implode(",", iterator_to_array($items));
}
echo describe(new Titles(["PHP", "PDO"])), PHP_EOL;
2:PHP,PDO
The parameter requires both interfaces. The example’s array element assumptions remain explicit rather than natively generic.
Documentation generics are not native runtime generics
PHP 8.3 does not provide native parameterized collection syntax such as Collection<Course> enforced by the engine. Tools may interpret PHPDoc templates, array shapes, and generic annotations for static analysis, but those are tool contracts rather than executable language declarations. Keep runtime validation where untrusted data enters and use analyzer notation consistently with the project’s selected tools. An array parameter alone says nothing about required keys or element classes. Do not advertise annotation coverage as a proof that every runtime value is valid, especially when reflection, serialization, or external payloads bypass ordinary construction paths.
Use types to communicate a maintained contract
Choose the smallest honest return model for the operation and avoid unions that merely collect unrelated failure conventions. A repository returning Course|null can describe expected absence, while storage failure should follow a distinct documented channel. Readonly values and enums can simplify states, but copying, serialization, and database conversion still need explicit rules. Test allowed cases, rejected cases, and version compatibility separately. When adopting newer syntax such as PHP 8.4 property hooks, update the minimum runtime intentionally and verify consumers before using it in a file that older runtimes must parse. A runtime if cannot protect unsupported syntax from parsing.
Common errors and fixes
PHPDoc templates are called native generics
- Why it happens
- Static-analysis notation is confused with engine syntax.
- How to fix it
- Label annotations as tool-specific and keep runtime boundary checks.
- Prevent it next time
- Document analyzer configuration and actual runtime support.
An enum case is treated as transition permission
- Why it happens
- Vocabulary validation replaces business-state rules.
- How to fix it
- Validate each permitted transition separately.
- Prevent it next time
- Test invalid and repeated events, not only state parsing.
A version check wraps unparseable new syntax
- Why it happens
- Runtime branching is mistaken for parser compatibility.
- How to fix it
- Keep newer syntax in version-bounded files or raise the declared minimum.
- Prevent it next time
- Lint with every claimed supported runtime in real compatibility work.
Check your understanding
1. Does array $courses ensure every element is a Course?
Show worked solution
No. It constrains the outer PHP value only. Validate elements at an external boundary or encapsulate a domain collection with controlled construction. A PHPDoc list<Course> may help a configured analyzer but is not an engine-enforced generic declaration.
2. Which enum conversion fits an untrusted optional filter?
Show worked solution
tryFrom can return null for an unknown backing value so the boundary can produce a deliberate validation error or allowed absence. Do not silently turn an invalid nonempty value into the default state unless the product explicitly permits that policy.
Key takeaways
- Types describe alternatives and capabilities, not complete domain validity.
- Version and analyzer boundaries must remain explicit.