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
┌─────────────────────┬──────────────────────────────────────────────────┐
│ 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.
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.
// 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.
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.
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.
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:
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.
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
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.
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.
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
__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?