Modern PHP 8 features in practice
Property hooks, asymmetric visibility, the pipe operator, clone-with, attributes, and the rewritten DOM and URI extensions.
Property hooks and asymmetric visibility
<?php
declare(strict_types=1);
class User
{
// asymetric visibility: the world reads the id, only this class writes it
public private(set) string $id;
public string $fullName {
get => $this->first . " " . $this->last;
set (string $value) {
[$this->first, $this->last] = explode(" ", $value, 2) + ["", ""];
}
}
public function __construct(string $id, public string $first = "", public string $last = "")
{
$this->id = $id;
}
}
$u = new User("u-1", "Ada", "Lovelace");
echo $u->fullName;
$u->fullName = "Grace Hopper"; // the setter splits it
// $u->id = "x"; // Error: cannot modify from outside- A hook turns an ordinary property into a computed one, so callers keep using
$obj->nameinstead ofgetFullName(). - An asymmetric property has one visibility for reading and another for writing - exactly what identifiers need.
- The backing value is
$this->propinside the hook; a virtual property (agetwith no stored value) must not be written without asethook.
Pipe operator and clone-with
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn(string $s): string => preg_replace('/[^a-z0-9]+/', '-', $s));
// equivalent without the pipe, read inside out
$slug = preg_replace('/[^a-z0-9]+/', '-', strtolower(trim($title)));
$draft = clone($post, ["status" => Status::Draft, "publishedAt" => null]);The pipe passes the left-hand value as the argument of the callable on the right, so a chain reads in execution order. clone($object, [ ... ]) produces a modified copy without a temporary variable and without mutating the original.
⚠️
Both are recent additions: the pipe and clone-with land in 8.5, hooks and asymmetric visibility in 8.4. Check the deployment target first, and make sure PHPStan, the formatter and the runtime all understand the same version.
Attributes and the new DOM and URI extensions
#[\Attribute]
final class Route
{
public function __construct(public string $path, public string $method = "GET")
{
}
}
#[Route("/health")]
function health(): string
{
return "ok";
}
#[\Deprecated("Use Client::send() instead")]
function send_request(): void
{
}
$ref = new ReflectionFunction("health");
foreach ($ref->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance(); // the attribute is an object again
echo $route->method . " " . $route->path;
}
$doc = Dom\HTMLDocument::createFromString("<p>hi</p>"); // 8.4 DOM API
$uri = Uri\Rfc3986\Uri::parse("https://example.com/a?b=1");
echo $uri->getHost();| Feature | Problem it removes |
|---|---|
| Attributes | Configuration living in docblock strings that nothing checks |
#[\Deprecated] | Deprecations that only appear in a changelog |
| New DOM | The awkward loadHTML / saveHTML pair and its global state |
| New URI | Hand-rolled parsing of query strings and relative references |
| Property hooks | Getter and setter boilerplate for simple derived values |
FAQ
Do attributes affect behaviour on their own?
No. An attribute is metadata until something reads it through reflection - a router, a validator, a test runner. Without a consumer it changes nothing.
Should I adopt hooks on an existing class?
Start with new classes. Keeping
getX() and setX() on public APIs avoids a flag day, and you can add hooks behind them later.Related
Object-oriented PHP Errors, exceptions and logging
Last refreshed 2026-09-18.