Magic Methods

PHP · Lesson 36 of 50 9 min read Updated

Magic methods are methods with special names that start with two underscores, such as __construct() and __toString(). You never call them yourself. PHP calls them automatically when something happens to an object: it is created, printed, copied, called like a function, or asked for a property that does not exist.

You already use one magic method, the constructor, from Classes & Objects. In this lesson you will meet the others, see where frameworks rely on them, and learn when they make code harder to understand, which is just as important.

The magic method family

MethodPHP calls it when...
__construct()An object is created with new
__destruct()The object is no longer used and is destroyed
__toString()The object is used as a string
__get(), __set()Code reads or writes a property that is private or does not exist
__isset(), __unset()isset() or unset() is used on such a property
__call(), __callStatic()Code calls a method that is private or does not exist
__invoke()The object is called like a function: $obj()
__clone()The object is copied with clone
__serialize(), __unserialize()The object is turned into a string with serialize() and back
__debugInfo()The object is shown with var_dump()

All magic methods except __construct(), __destruct() and __clone() must be public.

__construct and __destruct

The constructor runs when the object is born; the destructor runs when the last variable holding the object disappears, or when the script ends. Destructors are useful for clean-up, such as closing a file or releasing a lock:

PHP
<?php
declare(strict_types=1);

final class Report
{
    public function __construct(private string $name)
    {
        echo "Opening {$this->name}\n";
    }

    public function __destruct()
    {
        echo "Closing {$this->name}\n";
    }
}

function monthlyReport(): void
{
    $report = new Report('October sales');
    echo "Adding rows...\n";
}   // $report goes out of scope here

monthlyReport();
echo "Back in the main code\n";
Output
Opening October sales
Adding rows...
Closing October sales
Back in the main code

$report exists only inside the function, so the object is destroyed as soon as the function ends. Do not put important work, such as saving data, in a destructor: if the script crashes or is stopped, it may never run.

__toString: objects as text

__toString() decides what an object looks like as a string. It is perfect for value objects, small objects that represent a value such as an amount of money:

PHP
<?php
declare(strict_types=1);

final class Money
{
    public function __construct(private int $paise) {}

    public function __toString(): string
    {
        return '₹' . number_format($this->paise / 100, 2);
    }
}

$price = new Money(129900);

echo $price, "\n";                         // echo converts it
echo 'Total: ' . $price . "\n";            // so does joining strings
var_dump((string) $price);                 // and an explicit cast
var_dump($price instanceof Stringable);    // added automatically

try {
    var_dump(str_contains($price, '1,299'));
} catch (TypeError $e) {
    echo $e->getMessage(), "\n";
}
Output
₹1,299.00
Total: ₹1,299.00
string(11) "₹1,299.00"
bool(true)
str_contains(): Argument #1 ($haystack) must be of type string, Money given

Since PHP 8.0, any class with __toString() automatically implements the Stringable interface, so you can type a parameter as string|Stringable. Note the last line: with strict_types, a function that wants a string does not convert the object for you. Cast it with (string) $price first. Storing money in paise (whole numbers) avoids the rounding problems of floats.

__get, __set, __isset and __unset

These four run when outside code touches a property it cannot see: one that is private, or one that does not exist at all. They let a class decide what "reading" and "writing" a property means. Here a settings object stores values in a private array but allows only known names:

PHP
<?php
declare(strict_types=1);

final class Settings
{
    private array $values = [];

    public function __construct(private readonly array $allowed) {}

    public function __get(string $name): mixed
    {
        if (!in_array($name, $this->allowed, true)) {
            throw new LogicException("Unknown setting: $name");
        }
        return $this->values[$name] ?? null;
    }

    public function __set(string $name, mixed $value): void
    {
        if (!in_array($name, $this->allowed, true)) {
            throw new LogicException("Unknown setting: $name");
        }
        $this->values[$name] = $value;
    }

    public function __isset(string $name): bool
    {
        return isset($this->values[$name]);
    }

    public function __unset(string $name): void
    {
        unset($this->values[$name]);
    }
}

$site = new Settings(['shopName', 'city', 'gstRate']);
$site->shopName = 'Ananya Handlooms';     // calls __set
$site->city = 'Kochi';

echo $site->shopName, ', ', $site->city, "\n";   // calls __get
var_dump(isset($site->gstRate));                // calls __isset
unset($site->city);                             // calls __unset
var_dump($site->city);

try {
    $site->shopname = 'typo';                   // wrong case: not allowed
} catch (LogicException $e) {
    echo $e->getMessage(), "\n";
}
Output
Ananya Handlooms, Kochi
bool(false)
NULL
Unknown setting: shopname

Always implement __isset() together with __get(). Without it, isset($site->city) and $site->city ?? 'none' return "not set" even when __get() would return a value. And always throw for unknown names: silently accepting a typo like shopname creates bugs that are very hard to find.

For a single property that needs checks, PHP 8.4 property hooks are now the cleaner tool, because the property is still visible to your editor. See Enums, Readonly & Property Hooks.

__call and __callStatic

__call() runs when code calls a method that does not exist (or is private). It receives the method name and an array of arguments. __callStatic() does the same for static calls. Laravel's "facades" such as Cache::get() and its dynamic where...() query methods are built this way:

PHP
<?php
declare(strict_types=1);

final class StudentList
{
    public function __construct(private array $rows) {}

    // Handles calls such as findByCity('Pune') or findByName('Isha')
    public function __call(string $method, array $args): array
    {
        if (!str_starts_with($method, 'findBy')) {
            throw new BadMethodCallException("Method $method does not exist");
        }
        $field = lcfirst(substr($method, 6));          // 'findByCity' -> 'city'
        return array_values(array_filter(
            $this->rows,
            fn(array $row): bool => ($row[$field] ?? null) === $args[0]
        ));
    }
}

final class Log
{
    private static array $lines = [];

    // Handles Log::info(...), Log::warning(...), Log::error(...)
    public static function __callStatic(string $level, array $args): void
    {
        if (!in_array($level, ['info', 'warning', 'error'], true)) {
            throw new BadMethodCallException("Unknown log level: $level");
        }
        self::$lines[] = strtoupper($level) . ': ' . $args[0];
    }

    public static function all(): array
    {
        return self::$lines;
    }
}

$list = new StudentList([
    ['name' => 'Aarav', 'city' => 'Rohtak'],
    ['name' => 'Isha', 'city' => 'Pune'],
    ['name' => 'Rohan', 'city' => 'Pune'],
]);

foreach ($list->findByCity('Pune') as $s) {
    echo $s['name'], "\n";
}

Log::info('Order ORD-1042 placed');
Log::error('Payment gateway timed out');
echo implode("\n", Log::all()), "\n";

try {
    $list->deleteAll();
} catch (BadMethodCallException $e) {
    echo $e->getMessage(), "\n";
}
Output
Isha
Rohan
INFO: Order ORD-1042 placed
ERROR: Payment gateway timed out
Method deleteAll does not exist

BadMethodCallException is the built-in exception meant for exactly this case. Without that throw, a misspelt method name would quietly do nothing.

__invoke: objects you can call

A class with __invoke() produces objects that can be called like functions. They count as callables, so you can pass them to array_filter(), usort() or anything else that wants a callback (see Closures & Callables). Unlike a closure, the object can have a name, a constructor and tests of its own:

PHP
<?php
declare(strict_types=1);

final class PriceBetween
{
    public function __construct(private int $min, private int $max) {}

    public function __invoke(array $product): bool
    {
        return $product['price'] >= $this->min && $product['price'] <= $this->max;
    }
}

$products = [
    ['name' => 'Cotton saree', 'price' => 1499],
    ['name' => 'Silk saree', 'price' => 8999],
    ['name' => 'Kurta set', 'price' => 1199],
];

$midRange = new PriceBetween(1000, 2000);

var_dump($midRange(['price' => 1500]));   // call the object like a function

foreach (array_filter($products, $midRange) as $p) {
    echo $p['name'], "\n";
}
Output
bool(true)
Cotton saree
Kurta set

Frameworks use invokable classes for "single action controllers" and job handlers: classes that do exactly one thing.

__clone: deep copies

clone makes a shallow copy: the new object gets copies of simple values, but object properties still point at the same inner objects. __clone() runs on the new copy, so you can clone the inner objects too:

PHP
<?php
declare(strict_types=1);

final class Address
{
    public function __construct(public string $city) {}
}

final class Order
{
    public function __construct(public string $id, public Address $deliverTo) {}

    public function __clone(): void
    {
        $this->deliverTo = clone $this->deliverTo;   // copy the inner object too
        $this->id .= '-COPY';
    }
}

$first = new Order('ORD-7', new Address('Jaipur'));
$repeat = clone $first;               // runs __clone on the copy
$repeat->deliverTo->city = 'Pune';

echo $first->id, ' goes to ', $first->deliverTo->city, "\n";
echo $repeat->id, ' goes to ', $repeat->deliverTo->city, "\n";
Output
ORD-7 goes to Jaipur
ORD-7-COPY goes to Pune

Remove the first line of __clone() and the first order would also move to Pune, because both orders would share one Address object.

__serialize, __unserialize and __debugInfo

serialize() turns an object into a string that can be stored in a file, a cache or a session; unserialize() rebuilds it. __serialize() returns an array of exactly what to save, and __unserialize() receives that array. __debugInfo() controls what var_dump() shows, which keeps secrets out of debug output:

PHP
<?php
declare(strict_types=1);

final class UserSession
{
    private ?PDO $db = null;          // a live connection cannot be saved

    public function __construct(
        public string $name,
        public string $city,
        private string $passwordHash,
    ) {}

    public function __serialize(): array
    {
        return ['name' => $this->name, 'city' => $this->city, 'v' => 2];
    }

    public function __unserialize(array $data): void
    {
        $this->name = $data['name'];
        $this->city = $data['city'];
        $this->passwordHash = '';      // never stored, never restored
    }

    public function __debugInfo(): array
    {
        return ['name' => $this->name, 'city' => $this->city, 'passwordHash' => '***hidden***'];
    }
}

$user = new UserSession('Meera', 'Kochi', password_hash('secret', PASSWORD_DEFAULT));

$saved = serialize($user);
echo $saved, "\n";

$back = unserialize($saved, ['allowed_classes' => [UserSession::class]]);
echo $back->name, ' from ', $back->city, "\n";

var_dump($user);
Output
O:11:"UserSession":3:{s:4:"name";s:5:"Meera";s:4:"city";s:5:"Kochi";s:1:"v";i:2;}
Meera from Kochi
object(UserSession)#1 (3) {
  ["name"]=>
  string(5) "Meera"
  ["city"]=>
  string(5) "Kochi"
  ["passwordHash"]=>
  string(12) "***hidden***"
}

The saved string contains no password hash and no database connection. The 'v' => 2 entry is a version number, which helps __unserialize() read data saved by older versions of the class.

You will see __sleep() and __wakeup() in older code, and sometimes the Serializable interface. They did the same job less safely; __serialize() and __unserialize() (PHP 7.4) replace them, and PHP 8.5 soft-deprecates __sleep() and __wakeup().

When not to use magic

Magic methods are powerful, but they hide behaviour. Your editor cannot autocomplete $site->shopName or findByCity(), static analysis tools cannot check them, and a reader must find the magic method to understand a single line. Prefer real methods, typed properties and property hooks, and use magic when it removes a lot of repetitive code. If you do use __get() or __call(), document the virtual members with @property and @method comments, so editors and tools such as PHPStan understand them, and always throw for unknown names.

Common mistakes

  • Writing to undefined properties. Since PHP 8.2, $obj->newProp = 1 on a normal class shows "Creation of dynamic property ... is deprecated". Declare the property, or use __set() deliberately.
  • __get() without __isset(). isset() and ?? then report every magic property as missing.
  • Silently ignoring unknown names. Throw LogicException or BadMethodCallException instead.
  • Expecting clone to copy inner objects. It is shallow; use __clone() for a deep copy.
  • Unserializing untrusted data. Use JSON for anything that comes from users.

Key points

  • Magic methods have names starting with __ and are called by PHP, not by you.
  • __toString() makes an object usable as text and adds the Stringable interface.
  • __get(), __set(), __isset() and __unset() handle inaccessible properties; __call() and __callStatic() handle unknown methods.
  • __invoke() makes an object callable, which is handy for callbacks and single-action classes.
  • __clone() turns a shallow copy into a deep one; __serialize(), __unserialize() and __debugInfo() control saving and debugging.
  • Magic hides behaviour from readers and tools: use it sparingly, document it and throw for unknown names.

Exercise

Create a class Temperature that stores degrees Celsius as a private float. Add __toString() that returns text like 36.6 °C, and __get() that supports two virtual properties: celsius and fahrenheit (F = C × 9 / 5 + 32, rounded to one decimal). Any other name should throw a LogicException. Add a matching __isset(). Show echo $t, $t->fahrenheit and isset($t->kelvin) for 36.6 °C.

Show solution
PHP
<?php
declare(strict_types=1);

final class Temperature
{
    public function __construct(private float $celsius) {}

    public function __toString(): string
    {
        return $this->celsius . ' °C';
    }

    public function __get(string $name): float
    {
        return match ($name) {
            'celsius' => $this->celsius,
            'fahrenheit' => round($this->celsius * 9 / 5 + 32, 1),
            default => throw new LogicException("Unknown property: $name"),
        };
    }

    public function __isset(string $name): bool
    {
        return in_array($name, ['celsius', 'fahrenheit'], true);
    }
}

$t = new Temperature(36.6);
echo $t, "\n";
echo $t->fahrenheit, " °F\n";
var_dump(isset($t->kelvin));
Output
36.6 °C
97.9 °F
bool(false)

match with a throw in the default arm keeps __get() short and makes unknown names fail loudly. __isset() lists the same two names, so isset() agrees with __get().

Quick Quiz

1. When does PHP call __get()?

2. What does clone $order copy?

3. Which magic method makes $filter($item) work on an object?

4. Why should you never unserialize() a cookie value?

Learn PHP with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call