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

  1. PHP parses $obj->member or $obj->member() and looks up member in the object’s property or method table.
  2. If a matching, accessible property or method is found (respecting public/protected/private from the current scope), PHP uses it directly — no magic method is involved, and this path is faster.
  3. If no accessible match is found, the engine checks whether the class defines the corresponding magic method (__get, __set, __call, etc.).
  4. 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.
  5. 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/__call when 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(), so isset(), empty(), and unset() 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 a BadMethodCallException for unrecognized names instead of silently returning null — 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 @property and @method PHPDoc annotations on the class, since editors and static analyzers can’t see them otherwise.

Practice Exercises

  • Write a Temperature class that stores degrees Celsius internally and implements __toString() to print the value formatted as, for example, "36.6°C".
  • Build an ImmutableBag class that uses __get() to read values from an internal array, and implements __set() so that it throws a LogicException whenever anyone tries to write a property after construction (hint: accept the initial data as a constructor argument only).
  • Create a MethodLogger class whose __call() method prints the method name and arguments it was invoked with, then returns null. 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 __clone handle object lifecycle events.
  • __get, __set, __isset, and __unset intercept access to inaccessible or undefined properties — but only when there is no directly accessible real property.
  • __call and __callStatic intercept calls to undefined instance and static methods, respectively.
  • __toString and __invoke control 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.