PHP Magic Methods (__construct, __toString, etc.)
Every PHP object interacts with the language through a set of special hook methods called magic methods — method names, always prefixed with two underscores like __construct or __toString, that PHP calls automatically at specific moments: when an object is created or destroyed, printed or cast to a string, cloned, or when code tries to read a property or call a method that doesn’t actually exist on it. Magic methods let you customize behavior the language would otherwise handle rigidly. They are the foundation of fluent query builders, ORMs, value objects, lazy-loading proxies, and much of the "native-feeling" PHP code you see in modern frameworks.
Overview: How Magic Methods Work
Internally, every class the Zend engine compiles has a table of declared properties and a table of declared methods. When you write $obj->name or $obj->doThing(), the engine first tries to resolve that member directly against those tables, respecting visibility (public, protected, private) and the calling scope. Magic methods only get invoked when that direct resolution fails — either because the member truly does not exist, or because it exists but is inaccessible from the current scope (for example, a private property accessed from outside the class). This is why magic methods are often called overloading methods: PHP doesn’t support true method/property overloading (multiple signatures for one name) the way Java or C++ do, but magic methods simulate a similar effect by intercepting otherwise-invalid access.
Magic methods fall into three broad groups. Lifecycle methods run at fixed points in an object’s life: __construct when it is created with new, __destruct when it is garbage-collected or the script ends, and __clone when it is duplicated with the clone keyword. Overloading methods intercept inaccessible member access: __get/__set for property reads/writes, __isset/__unset for isset()/unset(), and __call/__callStatic for instance and static method calls. Conversion methods control how an object behaves in a specific context: __toString when used as a string, __invoke when called like a function, and __serialize/__unserialize (or the older __sleep/__wakeup) when passed through serialize().
Because magic methods are just regular methods with reserved names, PHP recognizes them by name and signature, not by any special keyword. If you misspell one (__constuct) or get the parameter count wrong, PHP silently treats it as an ordinary method that is simply never called by the engine — there is no error, which makes typos in magic method names a sneaky source of bugs.
Syntax
The general shape is a normal method declaration using one of the reserved names. The table below summarizes the most important ones.
| Method | Triggered when | Typical signature |
|---|---|---|
__construct() |
Object created with new |
public function __construct(...$args) |
__destruct() |
Object destroyed or script ends | public function __destruct(): void |
__toString() |
Object used in a string context (echo, concatenation, cast) |
public function __toString(): string |
__get() |
Reading an inaccessible/undefined property | public function __get(string $name): mixed |
__set() |
Writing an inaccessible/undefined property | public function __set(string $name, mixed $value): void |
__isset() |
isset()/empty() on an inaccessible/undefined property |
public function __isset(string $name): bool |
__unset() |
unset() on an inaccessible/undefined property |
public function __unset(string $name): void |
__call() |
Calling an inaccessible/undefined instance method | public function __call(string $name, array $arguments): mixed |
__callStatic() |
Calling an inaccessible/undefined static method | public static function __callStatic(string $name, array $arguments): mixed |
__invoke() |
Object called as a function: $obj() |
public function __invoke(...$args): mixed |
__clone() |
Object duplicated with clone $obj |
public function __clone(): void |
__serialize() / __unserialize() |
serialize() / unserialize() |
public function __serialize(): array |
__debugInfo() |
var_dump() on the object |
public function __debugInfo(): array |
Examples
Example 1: __construct, __toString, and __destruct
Constructor promotion lets you declare and assign properties directly in the parameter list. __toString defines how the object prints, and __destruct runs cleanup logic when the object is no longer needed.
<?php
final class Product
{
public function __construct(
private string $name,
private float $price,
private int $quantity = 1
) {
echo "Created product: {$this->name}\n";
}
public function __toString(): string
{
$total = $this->price * $this->quantity;
return sprintf("%s x%d = $%.2f", $this->name, $this->quantity, $total);
}
public function __destruct()
{
echo "Destroying product: {$this->name}\n";
}
}
$product = new Product("Wireless Mouse", 24.99, 3);
echo $product . "\n";
echo "Product cast to string: " . (string) $product . "\n";
Output:
Created product: Wireless Mouse
Wireless Mouse x3 = $74.97
Product cast to string: Wireless Mouse x3 = $74.97
Destroying product: Wireless Mouse
Notice the constructor body runs after promoted properties are assigned, so $this->name is already set inside it. Both the explicit concatenation and the explicit (string) cast trigger __toString(). The destructor fires automatically when the script ends and $product is cleaned up — you never call it yourself.
Example 2: __get, __set, __isset, and __unset
These four methods let a class store data in a private array while exposing it through ordinary-looking property syntax, which is the basis of many configuration and data-transfer objects.
<?php
class Config
{
private array $data = [];
public function __get(string $name): mixed
{
echo "Reading '{$name}'\n";
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
echo "Setting '{$name}' to '{$value}'\n";
$this->data[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->data[$name]);
}
public function __unset(string $name): void
{
echo "Removing '{$name}'\n";
unset($this->data[$name]);
}
}
$config = new Config();
$config->timeout = 30;
echo $config->timeout . "\n";
var_dump(isset($config->timeout));
unset($config->timeout);
var_dump(isset($config->timeout));
Output:
Setting 'timeout' to '30'
Reading 'timeout'
30
bool(true)
Removing 'timeout'
bool(false)
Every read, write, isset(), and unset() on $config->timeout is routed through the matching magic method because timeout is not a real declared property — only the private $data array is. This is exactly how frameworks build flexible, attribute-bag-style objects without predeclaring every possible field.
Example 3: __call, __callStatic, and __invoke
__call intercepts undefined instance methods, __callStatic intercepts undefined static methods, and __invoke lets an object be used as if it were a function.
<?php
class Calculator
{
private float $result;
public function __construct(float $start = 0)
{
$this->result = $start;
}
public function __call(string $name, array $arguments): static
{
$operations = [
'add' => fn($a, $b) => $a + $b,
'subtract' => fn($a, $b) => $a - $b,
];
if (!isset($operations[$name])) {
throw new BadMethodCallException("Unknown operation: {$name}");
}
$this->result = $operations[$name]($this->result, $arguments[0]);
return $this;
}
public static function __callStatic(string $name, array $arguments): string
{
return "Static call to '{$name}' with arguments: " . implode(', ', $arguments);
}
public function __invoke(): float
{
return $this->result;
}
}
$calc = new Calculator(10);
$calc->add(5)->subtract(3);
echo $calc() . "\n";
echo Calculator::describe('add', 'subtract') . "\n";
Output:
12
Static call to 'describe' with arguments: add, subtract
add() and subtract() don’t exist as real methods, so each call is routed through __call, which looks the operation name up in a small dispatch table and returns $this to allow chaining. Calculator::describe() doesn’t exist either, so it goes through __callStatic instead. Finally, $calc() invokes the object directly via __invoke.
How It Works Step by Step
- PHP parses
$obj->memberor$obj->member()and looks upmemberin the object’s property or method table. - If a matching, accessible property or method is found (respecting
public/protected/privatefrom the current scope), PHP uses it directly — no magic method is involved, and this path is faster. - If no accessible match is found, the engine checks whether the class defines the corresponding magic method (
__get,__set,__call, etc.). - If the magic method exists, PHP calls it with the member name (and value/arguments, where relevant) and uses its return value as the result of the original expression.
- If no magic method exists either, PHP raises a warning (for property access) or a fatal
Error(for undefined method calls).
The same fallback logic governs object lifecycle: new ClassName(...) allocates memory for the object and then calls __construct if defined; clone $obj performs a shallow, field-by-field copy of the object and then calls __clone on the new copy if defined, giving you a chance to deep-copy anything that shouldn’t be shared.
Common Mistakes
Mistake 1: Forgetting __isset() when using isset() on magic properties
Many developers assume that defining __get is enough for isset() to work on a magic property. It isn’t — isset() and empty() call __isset() exclusively, never __get().
<?php
class WrongConfig
{
private array $data = ['debug' => false, 'env' => 'production'];
public function __get(string $name): mixed
{
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
}
$config = new WrongConfig();
var_dump(isset($config->debug));
Output: bool(false) — even though the debug key exists in $data, PHP reports the property as unset because __isset() was never defined.
The fix is to implement __isset() alongside __get() and __set():
<?php
class RightConfig
{
private array $data = ['debug' => false, 'env' => 'production'];
public function __get(string $name): mixed
{
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->data[$name]);
}
}
$config = new RightConfig();
var_dump(isset($config->debug));
Output: bool(true).
Mistake 2: Not deep-cloning nested objects in __clone
By default, clone makes a shallow copy: object-typed properties still point to the same nested object. Without a custom __clone(), mutating a clone’s nested object also mutates the original.
<?php
class Engine
{
public function __construct(public int $horsepower) {}
}
class Car
{
public function __construct(public Engine $engine) {}
}
$original = new Car(new Engine(300));
$copy = clone $original;
$copy->engine->horsepower = 500;
echo $original->engine->horsepower . "\n";
var_dump($original->engine === $copy->engine);
Output:
500
bool(true)
Changing the clone’s engine unexpectedly changed the original’s engine too, because both point to the exact same Engine instance. Defining __clone() to clone nested objects fixes this:
<?php
class Engine
{
public function __construct(public int $horsepower) {}
}
class Car
{
public function __construct(public Engine $engine) {}
public function __clone(): void
{
$this->engine = clone $this->engine;
}
}
$original = new Car(new Engine(300));
$copy = clone $original;
$copy->engine->horsepower = 500;
echo $original->engine->horsepower . "\n";
var_dump($original->engine === $copy->engine);
Output:
300
bool(false)
Best Practices
- Only reach for
__get/__set/__callwhen you genuinely need dynamic behavior (data bags, proxies, fluent builders); for ordinary classes, declare real typed properties and methods — they are faster and give you IDE autocompletion and static analysis. - Always implement
__isset()and__unset()whenever you implement__get()and__set(), soisset(),empty(), andunset()behave consistently. - Declare a return type on
__toString()(: string) so PHP enforces that it can never accidentally return something else. - Implement
__clone()whenever a class holds properties that are objects, arrays of objects, or resources that shouldn’t be shared between the original and the copy. - In
__call/__callStatic, throw aBadMethodCallExceptionfor unrecognized names instead of silently returningnull— silent failures are hard to debug. - Avoid heavy logic inside
__destruct(); PHP does not guarantee a strict order of destruction at shutdown, and exceptions thrown from a destructor cannot always be caught cleanly. - Document magic properties and methods with
@propertyand@methodPHPDoc annotations on the class, since editors and static analyzers can’t see them otherwise.
Practice Exercises
- Write a
Temperatureclass that stores degrees Celsius internally and implements__toString()to print the value formatted as, for example,"36.6°C". - Build an
ImmutableBagclass that uses__get()to read values from an internal array, and implements__set()so that it throws aLogicExceptionwhenever anyone tries to write a property after construction (hint: accept the initial data as a constructor argument only). - Create a
MethodLoggerclass whose__call()method prints the method name and arguments it was invoked with, then returnsnull. Call three made-up methods on an instance and confirm each call is logged.
Summary
- Magic methods are reserved, double-underscore-prefixed methods that PHP invokes automatically at defined moments in an object’s life.
__construct,__destruct, and__clonehandle object lifecycle events.__get,__set,__isset, and__unsetintercept access to inaccessible or undefined properties — but only when there is no directly accessible real property.__calland__callStaticintercept calls to undefined instance and static methods, respectively.__toStringand__invokecontrol how an object behaves as a string and as a callable.isset()only triggers__isset(), never__get()— always implement both together.- Cloning is shallow by default; implement
__clone()to deep-copy nested objects when needed. - Prefer real properties and methods over magic ones whenever possible; reserve magic methods for genuinely dynamic scenarios.
