In part 1 we covered the features that most reshaped everyday PHP syntax: constructor promotion, readonly, enums, match, named arguments, and the null-handling operators. This second installment continues with features that are a little more specialized but just as worth knowing — especially the type-system improvements that arrived between PHP 8.1 and 8.4, and the property hooks that finally landed in 8.4.
#1. First-Class Callable Syntax
Before PHP 8.1, turning a method or function into a callable meant writing a string, a two-element array, or a closure wrapper. None of those were friendly to static analysis or refactoring tools.
1// The old ways
2$callable = 'strlen';
3$callable = [$this, 'handle'];
4$callable = Closure::fromCallable([$this, 'handle']);
5
6// The modern way
7$callable = strlen(...);
8$callable = $this->handle(...);
9$callable = MyClass::staticMethod(...);
The (...) syntax produces a proper Closure object. Because it's parsed as real code rather than a string, IDEs can rename across references, and tools like PHPStan or Psalm can verify the signature. It composes naturally with the higher-order array functions:
1$lengths = array_map(strlen(...), $words);
2$active = array_filter($users, fn (User $u) => $u->isActive());
3$names = array_map($this->extractName(...), $users);
It works for any callable form: named functions, instance methods, static methods, and even invokable objects. Think of it as a tiny, safer alternative to closures when you just want to hand off an existing function.
#2. The never Return Type
PHP 8.1 added never as a return type for functions that do not return at all — they either throw, call exit, or loop forever. It's different from void, which means "returns nothing but does return."
1function abort(string $message): never
2{
3 throw new RuntimeException($message);
4}
5
6function redirect(string $url): never
7{
8 header("Location: $url");
9 exit;
10}
The benefit is for static analyzers and for the type checker itself. When a function is declared never, the compiler knows control flow stops there, so this is valid:
1function getUser(?int $id): User
2{
3 if ($id === null) {
4 abort('User id is required'); // never returns
5 }
6
7 // No "possibly null" warning here — the analyzer knows we can't reach
8 // this point unless $id is an int.
9 return User::find($id);
10}
never is a "bottom type" in type-system terms — it sits below every other type, which is why it can stand in anywhere control flow is guaranteed to terminate.
#3. The true and false Standalone Types
PHP 8.2 introduced false and true as standalone types. They are useful for functions that already used false as a sentinel return value, which is a surprisingly common pattern in legacy PHP APIs.
1class Repository
2{
3 // Returns the user or false if not found — the type now expresses that
4 public function findOrFail(int $id): User|false
5 {
6 // ...
7 }
8}
9
10// Or pin a constant to a literal type
11class Feature
12{
13 public const ENABLED = true;
14
15 public function isEnabled(): true
16 {
17 return self::ENABLED;
18 }
19}
You'll mostly reach for false when modeling legacy-style returns, and for true when expressing that a method or constant has a fixed truthy result — for instance, a marker method that only exists to be discoverable by method_exists.
#4. Intersection and DNF Types
PHP 8.1 added pure intersection types, and 8.2 extended the system with Disjunctive Normal Form (DNF) types, which let you combine unions and intersections in one declaration.
An intersection type says a value must satisfy all of several interfaces:
1interface Countable { /* ... */ }
2interface Stringable { /* ... */ }
3
4function describe(Countable&Stringable $thing): string
5{
6 return (string) $thing . ' has ' . count($thing) . ' items';
7}
The parameter only accepts objects that implement both interfaces — no inheritance gymnastics required.
DNF types let you write a union where some of the branches are themselves intersections. The intersection parts must be wrapped in parentheses:
1function process((Countable&Stringable)|null $input): void
2{
3 if ($input === null) {
4 return;
5 }
6
7 echo "Processing: $input (" . count($input) . " items)\n";
8}
That signature means "either null, or an object that is both Countable and Stringable." Before DNF, you would have had to introduce a marker interface that extended both, which couples your domain to type-system plumbing.
#5. new in Initializers
For most of PHP's history, default parameter values had to be simple constants. Calling new in a default was a syntax error, which forced an awkward pattern of using null as a sentinel and constructing the real default inside the function body.
PHP 8.1 lifted that restriction. You can now use new expressions in parameter defaults, property defaults, static variable defaults, and global constants:
1class Logger
2{
3 public function __construct(
4 private Formatter $formatter = new JsonFormatter(),
5 private Handler $handler = new StreamHandler('php://stderr'),
6 ) {}
7}
8
9// Use the defaults, or substitute your own
10$default = new Logger();
11$custom = new Logger(formatter: new TextFormatter());
This makes dependency defaults explicit at the signature level. A reader can see what a class falls back to without scanning the constructor body, and IDE tooling can navigate to the default class directly.
One thing to keep in mind: defaults are evaluated at call time, not at definition time, so each call gets a fresh instance. That's almost always what you want, but it does mean these defaults aren't shared across calls.
#6. Readonly Promotion in Anonymous Classes
A small but neat addition in PHP 8.3: anonymous classes can now use the readonly modifier. Combined with constructor promotion, this gives you a quick way to build a typed value object inline:
1function fetchConfig(): object
2{
3 return new readonly class('https://api.example.com', 30) {
4 public function __construct(
5 public string $baseUrl,
6 public int $timeout,
7 ) {}
8 };
9}
10
11$config = fetchConfig();
12echo $config->baseUrl; // works
13$config->timeout = 60; // Error: Cannot modify readonly property
It's a small thing, but it removes a real friction point when prototyping or when you genuinely need a one-off typed bundle that shouldn't change.
#7. Typed Class Constants
PHP 8.3 finally allowed type declarations on class constants. Before, constants could hold any value, and the type was implicit in whatever you initialized it to.
1class Config
2{
3 const string VERSION = '2.1.0';
4 const int MAX_RETRIES = 3;
5 const array DEFAULTS = ['timeout' => 30, 'verify' => true];
6}
The benefit shows up in interfaces and abstract classes, where you want to require that implementers provide a constant of a specific type:
1interface HasVersion
2{
3 const string VERSION = '0.0.0';
4}
5
6class MyLibrary implements HasVersion
7{
8 const string VERSION = '1.4.2'; // must be a string
9}
If an implementer tries to override VERSION with an int, the engine catches it — something that was impossible to enforce before.
#8. Property Hooks (PHP 8.4)
Property hooks are the headline addition in PHP 8.4. They let you attach get and set logic directly to a property without writing accessor methods or hiding the field behind a getter/setter pair. If you've used C#'s properties or Kotlin's custom accessors, the model will feel familiar.
1class User
2{
3 public string $firstName;
4 public string $lastName;
5
6 // A computed property — no backing field needed
7 public string $fullName {
8 get => "$this->firstName $this->lastName";
9 }
10
11 // A property with validation on write
12 public string $email {
13 set (string $value) {
14 if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
15 throw new InvalidArgumentException("Invalid email: $value");
16 }
17 $this->email = strtolower($value);
18 }
19 }
20}
21
22$user = new User();
23$user->firstName = 'Ada';
24$user->lastName = 'Lovelace';
25echo $user->fullName; // "Ada Lovelace"
26$user->email = 'ADA@EX.COM'; // Stored as "ada@ex.com"
The key shift is that callers don't have to know whether they're talking to a plain field or a hooked one — the access syntax is the same. This means you can start with a public property and add validation or computation later without breaking any callers.
Hooks also work with interfaces, which previously couldn't declare anything beyond methods and constants:
1interface HasFullName
2{
3 public string $fullName { get; }
4}
Any class implementing this interface must expose a readable fullName property — whether it's stored, computed, or hooked is up to the implementation.
#9. Asymmetric Visibility (PHP 8.4)
PHP 8.4 also introduced asymmetric visibility, which lets a property be readable from one scope and writable from another. The classic case is "publicly readable but privately writable," which used to require a private property paired with a getter.
1class Order
2{
3 public function __construct(
4 public private(set) string $status = 'pending',
5 ) {}
6
7 public function ship(): void
8 {
9 $this->status = 'shipped'; // allowed — we're inside the class
10 }
11}
12
13$order = new Order();
14echo $order->status; // "pending"
15$order->status = 'cancelled'; // Error: Cannot modify private(set) property
16$order->ship(); // works
17echo $order->status; // "shipped"
The shorthand public private(set) can be written just as private(set) since public is the default read visibility. Combined with property hooks, this gives you fine-grained control over reads and writes without the ceremony of explicit accessor methods.
#Wrapping Up
The features in this installment are mostly about making PHP's type system precise enough to express what you actually mean — intersection types for "satisfies both," never for "doesn't return," typed constants for interface contracts, asymmetric visibility for "readable but not writable from outside." Property hooks tie the package together by letting you replace getter/setter pairs with plain property syntax, while keeping the validation and computation that motivated those methods in the first place.
Across both parts of the series, the throughline is the same: PHP is becoming more expressive without becoming heavier. The code you write today is shorter, safer, and more self-explanatory than the equivalent code from five years ago, and most of these features compose well — a readonly class with promoted, hooked properties and DNF parameter types is just regular modern PHP now, not a stunt.
Part 3 will look at the standard library improvements that often go unmentioned: the new array helpers added in 8.1 through 8.4, the Random extension, the json_validate function, and the changes to the date/time API.