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->name instead of getFullName().
  • An asymmetric property has one visibility for reading and another for writing - exactly what identifiers need.
  • The backing value is $this->prop inside the hook; a virtual property (a get with no stored value) must not be written without a set hook.

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();
FeatureProblem it removes
AttributesConfiguration living in docblock strings that nothing checks
#[\Deprecated]Deprecations that only appear in a changelog
New DOMThe awkward loadHTML / saveHTML pair and its global state
New URIHand-rolled parsing of query strings and relative references
Property hooksGetter 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.

Object-oriented PHP Errors, exceptions and logging

Last refreshed 2026-09-18.