PHP::OOP_Mastery
Overview / Session 07 / Session 08
Session 08 of 10  ·  2 hrs + 15 min review

Magic Methods

⏱ 2-hour session 📋 15-min review ⚠ Requires Sessions 01–07 🗂 4 topics

1 — What Magic Methods Are

Magic methods are special methods whose names begin with a double underscore (__). PHP calls them automatically in response to certain operations — you never invoke them directly. They are PHP's hook system: define one and the engine will call it at exactly the right moment.

You've already used two: __construct() (Session 03) runs when you write new, and __destruct() runs when the object is garbage-collected. This session covers the rest of the family — the ones that make PHP objects feel like they have superpowers.

The complete magic method map

PHP — All magic methods at a glance
┌─────────────────────┬──────────────────────────────────────────────────┐
│ Magic method        │ Called when…                                     │
├─────────────────────┼──────────────────────────────────────────────────┤
│ __construct()       │ new ClassName(…)                                 │
│ __destruct()        │ object is garbage-collected / script ends        │
│ __toString()        │ object used as a string: echo $obj, (string)$obj │
│ __invoke()          │ object called as a function: $obj(…)             │
│ __get($name)        │ reading an inaccessible/non-existent property    │
│ __set($name,$val)   │ writing an inaccessible/non-existent property    │
│ __isset($name)      │ isset() or empty() on inaccessible property      │
│ __unset($name)      │ unset() on inaccessible property                 │
│ __call($nm,$args)   │ calling an inaccessible/non-existent method      │
│ __callStatic(…)     │ calling an inaccessible static method            │
│ __clone()           │ clone $obj                                       │
│ __sleep()           │ serialize($obj) — return array of props to keep  │
│ __wakeup()          │ unserialize(…) — reinitialize after restore      │
│ __serialize()       │ serialize() — PHP 7.4+ replacement for __sleep   │
│ __unserialize()     │ unserialize() — PHP 7.4+ replacement             │
│ __debugInfo()       │ var_dump($obj) — control what is printed         │
└─────────────────────┴──────────────────────────────────────────────────┘

The golden rule

Magic methods are powerful but easy to abuse. Use them when they make your objects behave intuitively — when the magic maps to a natural operation a developer would expect. Avoid them when they hide behaviour that would be clearer as an explicit method. The test: would a teammate be surprised to find this code here?

2 — __toString() & __invoke()

__toString() — objects as strings

Define __toString() and your object can be used anywhere PHP expects a string: echo, string concatenation, sprintf(), string interpolation inside double quotes. It must return a string — returning anything else throws a fatal error.

PHP — __toString()
class Money
{
    public function __construct(
        private readonly float  $amount,
        private readonly string $currency
    ) {}

    public function __toString(): string
    {
        return number_format($this->amount, 2) . ' ' . $this->currency;
    }
}

$price = new Money(1299.9, 'EUR');

echo $price;                          // "1,299.90 EUR"
echo "Total: {$price}";               // "Total: 1,299.90 EUR"
echo "Price is " . $price . ".";     // "Price is 1,299.90 EUR."
sprintf("You owe: %s", $price);       // "You owe: 1,299.90 EUR"

// Also useful for logging — no manual ->toString() calls needed:
$logger->info("Charged: {$price}");   // reads naturally

Note: as of PHP 8.0, objects with __toString() also satisfy the Stringable interface automatically. You can type-hint Stringable to accept any object that can be cast to string.

PHP — Stringable interface (PHP 8.0+)
// Accept anything that can be used as a string
function renderLabel(string|Stringable $label): string
{
    return "<label>{$label}</label>";
}

renderLabel('plain string');    // works
renderLabel(new Money(10, 'USD')); // also works — Money has __toString()

__invoke() — objects as callables

__invoke() lets you call an object as if it were a function. When you write $obj($arg), PHP calls __invoke() with those arguments. This makes objects that represent a single action (middleware, validators, event handlers, transformers) feel natural and composable.

PHP — __invoke() as a callable object
class Multiplier
{
    public function __construct(private float $factor) {}

    public function __invoke(float $value): float
    {
        return $value * $this->factor;
    }
}

$double = new Multiplier(2);
$vat    = new Multiplier(1.2);

echo $double(50);    // 100  — called like a function!
echo $vat(100);      // 120

// Works anywhere PHP accepts a callable:
$prices    = [10.0, 25.5, 99.99];
$withVat   = array_map($vat, $prices);  // [12.0, 30.6, 119.988]
$doubled   = array_map($double, $prices); // [20.0, 51.0, 199.98]

// Check if an object is callable:
var_dump(is_callable($double));  // bool(true)

A common real-world pattern: middleware stacks in frameworks like Slim and Laravel accept callables — you can pass an invokable class instead of a closure, and the class can carry injected dependencies via its constructor.

PHP — Invokable middleware class
class AuthMiddleware
{
    public function __construct(private TokenValidator $validator) {}

    public function __invoke(Request $request, Response $response, callable $next): Response
    {
        $token = $request->getHeader('Authorization');

        if (! $this->validator->isValid($token)) {
            return $response->withStatus(401);
        }

        return $next($request, $response);
    }
}

// Register as middleware — framework calls it like a function internally
$app->add(new AuthMiddleware($validator));

3 — Property Overloading: __get(), __set(), __isset(), __unset()

These four magic methods let you intercept property access on an object — specifically, access to properties that are either inaccessible (private/protected in the wrong scope) or don't exist at all. PHP calls them only when normal property access fails; they do not replace direct access to declared public properties.

PHP — __get, __set, __isset, __unset
class FlexibleObject
{
    // Internal storage — all "virtual" properties land here
    private array $data = [];

    // Called when reading $obj->someUndeclaredProp
    public function __get(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }

    // Called when writing $obj->someUndeclaredProp = value
    public function __set(string $name, mixed $value): void
    {
        $this->data[$name] = $value;
    }

    // Called when isset($obj->someUndeclaredProp)
    public function __isset(string $name): bool
    {
        return isset($this->data[$name]);
    }

    // Called when unset($obj->someUndeclaredProp)
    public function __unset(string $name): void
    {
        unset($this->data[$name]);
    }
}

$obj = new FlexibleObject();

$obj->name  = 'Alice';          // calls __set('name', 'Alice')
$obj->score = 42;               // calls __set('score', 42)

echo $obj->name;                // calls __get('name') → "Alice"
echo $obj->missing;            // calls __get('missing') → null

var_dump(isset($obj->name));  // calls __isset('name') → true
var_dump(isset($obj->nope));  // calls __isset('nope') → false

unset($obj->name);            // calls __unset('name')

Real use-case: a dynamic data transfer object

__get() / __set() are the backbone of ORMs and template engines that allow $row->column_name access to database results without declaring every possible column as a typed property. Here's a clean, read-only version — a common pattern for API response objects:

PHP — Read-only data object with __get and __isset
class DataObject
{
    public function __construct(private array $attributes) {}

    public function __get(string $name): mixed
    {
        if (! array_key_exists($name, $this->attributes)) {
            throw new OutOfBoundsException("Property '{$name}' does not exist");
        }
        return $this->attributes[$name];
    }

    public function __isset(string $name): bool
    {
        return array_key_exists($name, $this->attributes);
    }

    // Block writes — this object is immutable
    public function __set(string $name, mixed $value): void
    {
        throw new LogicException("DataObject is read-only");
    }
}

// Usage feels like accessing object properties directly:
$user = new DataObject([
    'id'    => 42,
    'name'  => 'Alice',
    'email' => 'alice@example.com',
]);

echo $user->name;    // "Alice"
echo $user->id;      // 42
echo $user->missing; // OutOfBoundsException
$user->name = 'x';   // LogicException: read-only

__get() performance note

Property overloading through magic methods is significantly slower than direct property access — PHP must look up the method, check scope, and dispatch it. Use magic properties for convenience layers (DTOs, ORM row objects, proxies), not for hot loops or performance-critical code.

4 — Method Overloading, Serialization & __debugInfo()

__call() and __callStatic()

__call(string $name, array $args) is invoked when calling a method that doesn't exist or is inaccessible on an object instance. __callStatic() does the same for static calls. Both receive the method name and an array of all arguments.

PHP — __call() building a fluent query builder
class QueryBuilder
{
    private array  $wheres  = [];
    private ?int   $limit   = null;
    private ?string $orderBy = null;

    // Intercepts: ->whereStatus('active'), ->whereRole('admin'), etc.
    // Any "where{Column}" call is handled dynamically
    public function __call(string $name, array $args): static
    {
        if (str_starts_with($name, 'where')) {
            // Convert "whereUserStatus" → "user_status"
            $column = strtolower(preg_replace(
                '/[A-Z]/', '_$0',
                lcfirst(substr($name, 5))
            ));
            $this->wheres[] = "{$column} = ?";
            return $this;
        }

        throw new BadMethodCallException("Method {$name} does not exist");
    }

    public function limit(int $n): static
    {
        $this->limit = $n;
        return $this;
    }

    public function toSql(): string
    {
        $sql = 'SELECT *';
        if ($this->wheres) {
            $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
        }
        if ($this->limit !== null) {
            $sql .= " LIMIT {$this->limit}";
        }
        return $sql;
    }
}

// None of these "where*" methods are declared — __call() handles them all:
$query = (new QueryBuilder())
    ->whereStatus('active')
    ->whereRole('admin')
    ->limit(10)
    ->toSql();

echo $query;
// SELECT * WHERE status = ? AND role = ? LIMIT 10

__callStatic() — dynamic static methods

PHP — __callStatic() for named constructors
class Collection
{
    private function __construct(private array $items) {}

    // Intercepts Collection::from([…]), Collection::empty(), etc.
    public static function __callStatic(string $name, array $args): static
    {
        return match($name) {
            'from'  => new static($args[0] ?? []),
            'empty' => new static([]),
            default => throw new BadMethodCallException("Unknown: {$name}"),
        };
    }

    public function count(): int { return count($this->items); }
}

$c1 = Collection::from([1, 2, 3]);
$c2 = Collection::empty();
echo $c1->count(); // 3
echo $c2->count(); // 0

__sleep(), __wakeup(), __serialize(), __unserialize()

PHP's serialize() converts an object to a storable string. unserialize() reconstructs it. These magic methods let you control exactly what gets saved and what gets re-initialized.

PHP — __serialize / __unserialize (PHP 7.4+, preferred)
class DatabaseConnection
{
    private \PDO $pdo;      // resource — cannot be serialized
    private bool $connected = false;

    public function __construct(
        private string $dsn,
        private string $user,
        private string $pass
    ) {
        $this->connect();
    }

    private function connect(): void
    {
        $this->pdo       = new \PDO($this->dsn, $this->user, $this->pass);
        $this->connected = true;
    }

    // Called by serialize() — return only what can be stored
    public function __serialize(): array
    {
        return [
            'dsn'  => $this->dsn,
            'user' => $this->user,
            'pass' => $this->pass,
            // $pdo is intentionally omitted — PDO resources can't be serialized
        ];
    }

    // Called by unserialize() — restore the full object state
    public function __unserialize(array $data): void
    {
        $this->dsn  = $data['dsn'];
        $this->user = $data['user'];
        $this->pass = $data['pass'];
        $this->connect();  // re-establish the connection on restore
    }

    public function isConnected(): bool { return $this->connected; }
}

$db         = new DatabaseConnection('sqlite::memory:', '', '');
$serialized = serialize($db);       // __serialize() called — saves dsn/user/pass only
$restored   = unserialize($serialized); // __unserialize() called — reconnects
var_dump($restored->isConnected());  // bool(true)

__debugInfo() — clean var_dump output

By default, var_dump() prints every property, including passwords, tokens, and internal state you'd never want to expose in logs or debug output. __debugInfo() lets you control exactly what var_dump() and print_r() reveal.

PHP — __debugInfo() for safe debug output
class ApiClient
{
    public function __construct(
        private string $baseUrl,
        private string $apiKey,    // sensitive — must NOT appear in logs
        private string $secret     // sensitive
    ) {}

    public function __debugInfo(): array
    {
        return [
            'baseUrl' => $this->baseUrl,
            'apiKey'  => substr($this->apiKey, 0, 4) . '****',  // masked
            'secret'  => '[REDACTED]',
        ];
    }
}

$client = new ApiClient('https://api.example.com', 'sk_live_abc123xyz', 's3cr3t');
var_dump($client);
// object(ApiClient)#1 (3) {
//   ["baseUrl"]=> string(23) "https://api.example.com"
//   ["apiKey"]=>  string(8)  "sk_l****"
//   ["secret"]=>  string(10) "[REDACTED]"
// }

Practical summary: when to use each magic method

Decision guide
__toString()     → Value objects, loggers, template variables
__invoke()       → Single-action classes, middleware, validators, transformers
__get/__set()    → ORMs, DTOs, proxy objects, dynamic attribute bags
__isset/__unset()→ Always pair with __get/__set when you implement either
__call()         → Dynamic method dispatch, query builders, magic finders
__callStatic()   → Dynamic named constructors, static proxy facades
__serialize()    → Any class with resources (DB handles, file handles, sockets)
__debugInfo()    → Any class holding secrets, tokens, passwords, or credentials
__clone()        → Any class with nested object properties (deep copy)

15-Minute Review — Session 08

Switch the sidebar timer to review mode. Answer all questions without scrolling up first.

Q1 — You write echo $obj;. Which magic method does PHP call?

Q2 — You write $result = $obj($arg);. Which magic method is called?

Q3 — __get() is called when…

Q4 — What is the signature of __call()?

Q5 — Why should you implement __debugInfo() on a class holding an API key?

Q6 — When does __serialize() get called?

✓ Key concepts checklist

Next: Namespaces & Autoloading →