Skip to content
advanced Phase 5 · Interfaces, Traits, Enums, and Advanced Classes

Magic Methods like __get and __toString

Intercept property access, calls, and string conversion.

45m
0 problems
Topic Progress 0%

__get and __set: Virtual Properties, Lazy Loading, and the Undeclared Trap

Magic property methods run on one precise trigger: access to a property that is inaccessible or undefined. Not all access — declared public properties never invoke them; private properties accessed from outside the class do. That single sentence drives both their uses and their most infamous bug.

The sanctioned use is virtual properties: an attribute that looks like data but is computed or loaded on demand:

final class Product
{
    private array $cache = [];

    public function __get(string $name): mixed
    {
        return match ($name) {
            'priceWithTax' => $this->cache['priceWithTax'] ??= $this->loadPriceWithTax(),
            default => throw new \RuntimeException("Unknown property: $name"),
        };
    }

    public function __isset(string $name): bool
    {
        return $name === 'priceWithTax';
    }
}

$p = new Product();
echo $p->priceWithTax;          // first access: __get fires, loads, caches
isset($p->priceWithTax);        // __isset — never touches __get
$p->priceWithTax = 10;          // Error: Cannot access private property Product::$priceWithTax

That last line is the mechanism doing its job: $priceWithTax was never declared, so the write should be an error — and because no __set exists, PHP reports the underlying access violation. When you want writes to route through your code instead, declare __set(string $name, mixed $value): void, and pair it with __unset for unset($p->priceWithTax). The four methods are one interception family: __get (read), __set (write), __isset (existence probe — which empty()/isset() use without calling __get), __unset (deletion).

Now the trap that gave magic properties their bad reputation. Before PHP 8.2, a typo like $product->priceWitTax on a class with a permissive __get silently loaded a database row instead of raising a notice — a spelling mistake executing real work. PHP 8.2's dynamic-properties deprecation closed the write side of this hole, but the read side remains a design decision: __get should answer known virtual attributes and throw for everything else, exactly as the default => arm above does. A permissive __get that guesses turns every typo into a silent miss; a strict one turns it into an immediate, debuggable RuntimeException.

Two constraints to design around. __get should return a value, not echo or exit — recursion is your enemy, so any __get implementation must avoid reading the same property (infinite loop risk: $this->priceWithTax inside __get('priceWithTax') re-triggers __get). And unlike most magic methods, the full set here is not interchangeable with declared properties for performance: every intercepted access costs a method call plus a hash lookup on the name string. Use them at object boundaries — mappers, DTOs over document stores, Active-Record-style rows — not as a substitute for ordinary fields. The next family covers the other two contexts objects are commonly thrown into: strings and function calls.

A last defensive pattern for virtual properties: guard the interception with a declared allowlist rather than a guess-and-load fallback. In practice that means a private map from virtual name to loader closure, built once in the constructor, so __get performs a hash lookup and an explicit call instead of interpreting arbitrary strings — and the unknown-name exception becomes trivial to test. Frameworks that grew before typed properties, most famously the old ActiveRecord relation magic, show what happens without that discipline: a generation of bugs where a typo'd column name quietly queried the database. Modern PHP prefers declared typed properties, with __get reserved for the genuinely dynamic boundary of mapping storage documents into objects.

Enums from Backed Enums for Fixed Values deliberately forbid most of what follows here — no properties, no constructors, only __invoke and __call among the magic methods — which is why the interception toolkit belongs to classes with real state.

__toString and __invoke: Objects That Behave Like Values and Functions

Handing an object to echo, string interpolation, or a string-typed parameter is a fatal unless the class defines __toString(): string:

final class Money
{
    public function __construct(private int $cents, private string $currency) {}

    public function __toString(): string
    {
        return sprintf('%s %.2f', $this->currency, $this->cents / 100);
    }
}

$m = new Money(1250, 'EUR');
echo $m;                        // EUR 12.50
"Total: $m"                     // interpolation works too
json_encode($m);                // still public properties — __toString does NOT apply here
(string) $m;                    // explicit cast works

__toString is the class's declaration of its textual face, and it plugs into every string-expecting surface: interpolation, str_contains($m, 'EUR'), passing to a string-parameter function (PHP 7.4+ no longer throws for that call form — the conversion happens through __toString). Its failure rule has tightened with versions and is worth stating exactly: the method must return a string — returning anything else is a TypeError — and since PHP 7.4 it may throw, but an exception escaping __toString in a genuine string-conversion context (like echo) becomes fatal. Treat it as infallible: compute, format, return. What __toString does not affect: json_encode, which has its own protocol in JsonSerializable — classes commonly implement both, one for humans, one for APIs.

__invoke opens the other context. Any object with __invoke() is a valid PHP callable wherever callables are accepted:

final class Discount
{
    public function __construct(private float $rate) {}

    public function __invoke(float $total): float
    {
        return $total * (1 - $this->rate);
    }
}

$halfOff = new Discount(0.5);
$halfOff(80.0);                              // 40.0 — the object IS the function
array_map($halfOff, [10.0, 20.0]);           // callback position, no closure needed
usort($rows, new RowComparer());             // invokable comparator objects

This is the pattern behind modern PHP's invokable middleware, validators, and event subscribers: configuration in properties, behavior in __invoke, constructed once and passed around as a unit. Versus a closure, an invokable object carries a name, a type, constructor promotion, and can implement an interface (interface Middleware { public function __invoke(Request $r, callable $next): Response; }) — all things an anonymous function cannot. Versus a plain method call, $discount($total) reads as "apply the discount", which is the entire appeal and also the cost: a reader must know the class defines __invoke to predict what that line does. Use invokables where the ecosystem expects a callable; keep ordinary named methods where the action deserves a visible name at the call site. Both methods above share one virtue — they are narrow magic: one context, one behavior, fully predictable. The lifecycle family in the next chapter is where magic meets the object's birth, death, and copying.

Two context rules complete the pair. __toString participates in string interpolation and in loose comparison against strings, which converts through it — while === never converts at all, so equality checks remain identity checks unless you cast explicitly. And __invoke's reach extends into every callable-typed slot the language provides: array_filter with a Discount, usort with a comparator object, preg_replace_callback with a builder, and middleware queues typed as callable. One caution for readable code: an invokable that performs heavy work hides that work behind function-call syntax, so name the variable for the action — $applyDiscount = new Discount(0.5) — rather than leaving readers to guess what $x(80) does. Predictability is the price of admission for magic; pay it and these two methods stay unambiguous.

Birth, Death, and Copies: __construct, __destruct, __clone, and the __call Trap

Most "magic" in PHP is ordinary lifecycle plumbing you already use: __construct runs at new; __clone runs inside clone on the new object, which makes it the deep-copy hook:

final class Cart
{
    public function __construct(private array $lines = []) {}

    public function __clone(): void
    {
        foreach ($this->lines as $i => $line) {
            $this->lines[$i] = clone $line;   // re-clone nested objects — clone alone is shallow
        }
    }
}

$copy = clone $cart;    // scalar fields copied by the engine, nested lines re-cloned by __clone

Remember from Classes and Objects that clone copies properties shallowly; __clone is where you finish the job, running with $this already bound to the copy — mutate freely, the original is untouched.

__destruct is the counterpart, and its semantics in PHP are narrower than people assume. It runs when the object's last reference disappears (or at script end), which makes it a decent release point for prompt cleanup — and a poor one for correctness. In a web request the engine tears everything down at request end anyway; in long-running workers (queue consumers, ReactPHP/Swoole loops) an object held by a global registry is never collected, so __destruct never fires and anything relying on it leaks. The reliable cleanup story — closing files, releasing locks, rolling back transactions — is try/finally, not destructors, and the reasons land in Cleanup with finally Blocks. PHP also has no copy constructor and no deterministic destruction guarantee: design cleanup around explicit methods and scope, and treat __destruct as a best-effort backstop only.

The last member of the family, __call/__callStatic, intercepts undefined method calls and is the engine behind every ORM magic ($user->posts() routing to a query builder) and every RPC proxy:

final class RemoteApi
{
    public function __call(string $name, array $args): mixed
    {
        return $this->client->post("/$name", $args);   // $api->getOrders([...]) -> HTTP
    }
}

The power and the price are the same: no method exists, so no IDE completion, no signature, no static analyzer can see the contract, and a typo'd method name becomes an HTTP request instead of a compile error. The discipline that keeps proxies honest is to pair them with an interface describing the real methods (the proxy implements it, @method docblocks at minimum), and to have __call throw BadMethodCallException for anything outside the known set rather than forwarding blindly. With object interception fully mapped, the roadmap turns to the failure paths these objects will inevitably meet — starting with Throwing and Catching Exceptions.

Two lifecycle details frequently surface in interviews. First, __destruct fires when the object's reference count reaches zero or at script shutdown, and PHP's reference counting is eager — there is no waiting for a generational collector in normal operation, so destructors often run earlier than developers fear, though still too unpredictably to depend on for cleanup. Second, __clone composes predictably with clone expressions: the engine performs its shallow copy first, then invokes __clone exactly once on the new object, where a re-clone of nested objects does not recurse unless you write it to. As with every magic method, each has a fixed, documented context — learn the context tables and the methods stop being magic, which is the honest goal: predictable interception at named points, never behavior nobody can find.

Quiz

1. A class declares public $id and defines __get. When does __get run?

Question 1 options

2. What does a permissive __get do to a typo like $product->priceWitTax, and what is the PHP 8.2 angle?

Question 2 options

3. Which statements about __toString are correct in PHP 8?

Question 3 options

4. Why is relying on __destruct for resource cleanup risky in production PHP?

Question 4 options

Flashcards

Question

When do __get/__set/__isset/__unset fire, and what must a strict __get do?

Answer

On access to properties that are inaccessible or undefined: __get on read, __set on write, __isset on isset()/empty() (which never call __get), __unset on unset(). Declared public properties bypass all four. A strict __get answers known virtual attributes and throws for unknown names, so typos fail loudly instead of silently executing the fallback.

Question

What does __toString guarantee, and what does it NOT affect?

Answer

It makes an object valid in string contexts (echo, interpolation, string casts, string-typed parameters) and must return a string — anything else is a TypeError; exceptions since 7.4 are allowed but fatal in pure conversion contexts, so keep it infallible. It does not affect json_encode, which uses JsonSerializable or public properties.

Question

What does __invoke enable, and how does it compare to a closure?

Answer

An object with __invoke() is a valid callable anywhere callables are accepted (array_map, usort, middleware slots). Compared with closures it brings a name, a type, constructor-injected configuration, and interface implementation — at the cost of hiding the action behind $obj(...) syntax, so readers must know the class to predict the call.

Question

What is the execution context inside __clone, and why does it exist?

Answer

__clone runs inside `clone $x` with $this already bound to the NEW copy (the original is never touched). It exists because clone is shallow: scalar properties are copied by the engine while nested objects stay shared handles, so __clone re-clones them to produce a genuine deep copy.

Question

Why is __destruct unreliable for cleanup, and what does __call cost?

Answer

__destruct fires only when the object is collected — non-deterministic, never for globals in long-running workers, and moot at web-request teardown; deterministic cleanup is try/finally. __call/__callStatic intercept undefined methods (ORM magic, RPC proxies) but erase the contract: no signatures, no completion, no static analysis, and typos become runtime traffic — pair them with an interface and throw BadMethodCallException for unknown names.

Revision Notes

Key Takeaways

  • 1. Magic property methods fire only on inaccessible-or-undefined access — declared public properties bypass them entirely
  • 2. A permissive __get turns typos into silent behavior; strict __get throws, and PHP 8.2's dynamic-property deprecation closed the write side of the same hole
  • 3. __toString must return a string and does not affect json_encode; __invoke makes the object a first-class callable with typed, configurable state
  • 4. __clone runs on the copy and is the deep-copy hook; destructor timing is non-deterministic, so cleanup correctness belongs to finally, not __destruct
  • 5. __call powers ORM and RPC magic by erasing the method contract — pair it with an interface and reject unknown names loudly

Interview Tips

  • • "When does __get execute?" — precisely on inaccessible-or-undefined property reads; name the four-member family (__get/__set/__isset/__unset) and note isset()/empty() never trigger __get
  • • "How would you lazy-load a related record?" — virtual property via __get with caching and a throwing default arm; mention the 8.2 dynamic-properties deprecation as the related trap for undeclared writes
  • • "__toString or JsonSerializable?" — different contexts: __toString is human-facing strings and must be infallible; JsonSerializable owns the wire format that json_encode emits
  • • "Are magic methods a good idea?" — use them narrow and predictable (lifecycle hooks, string/callable contexts, strict virtual properties); be ready to criticize __call proxies for hiding contracts from static analysis

Cheat Sheet

Magic Methods

Property interception — fires only on inaccessible-or-undefined property access:

public function __get(string $name): mixed    // read  — return known virtuals, throw on the rest
public function __set(string $name, mixed $v): void  // write
public function __isset(string $name): bool   // isset()/empty() — never calls __get
public function __unset(string $name): void   // unset()

Contexts:

echo $m;                  // __toString(): string — MUST return string (else TypeError)
$halfOff(80.0);           // __invoke(float $t): float — object as callable
clone $cart;              // __clone(): void — $this is the copy; finish the deep copy here

Lifecycle: __construct at new; __clone inside clone (new object); __destruct on collection — non-deterministic, skipped for globals in workers, moot at web teardown. Cleanup correctness belongs to try/finally, not destructors.

Undefined-method proxy:

public function __call(string $name, array $args): mixed
{
    if (!in_array($name, self::KNOWN, true)) {
        throw new \BadMethodCallException($name);
    }
    return $this->client->post("/$name", $args);
}

Pair with an interface or @method docblocks — otherwise no signatures, no completion, no static analysis.

Guardrails: unknown-name __get throws (8.2 also deprecated dynamic-property writes); __get must avoid re-reading its own property (recursion); json_encode ignores __toString (use JsonSerializable).

Continue Learning