---
title: 'Magic Methods'
source: 'https://academia.sh/en/courses/php-object-model/magic-methods'
course: 'Object-Oriented PHP and Standards'
language: en
updated: '2026-10-21T05:03:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines the __get, __set, __isset, __call, and __toString methods and observes when they are called'
    - 'Measures how renaming an internal name affects the consumer when a property read is turned into a method'
    - 'Shows that the same change stops the consumer when __get rejects an unknown name'
---

# Magic Methods

The __get, __set, __isset, __call, and __toString methods, resolving a name that is read through a method, and the implicit and breaking results the consumer meets when that resolution changes.

In the Static Members and Constants lesson, the consumer shifted without an error when the value of a constant changed. With magic methods the same silence spreads over a larger area, because reading a property or calling a method can now be turned into a method inside the class. The question is what the consumer sees when the internal name behind this translation changes.

## Magic Methods

Magic methods are methods whose names begin with two underscores and which PHP itself calls in certain situations. `__get` runs when a property that does not exist or is inaccessible is read, `__set` when it is written, `__isset` when it is queried with `isset()`, `__call` when a method that does not exist is called, and `__toString` when an object is used as a string.

```php
<?php
// Setting.php — a class that catches reading, writing, querying, method calls, and conversion to a string
class Setting
{
    private array $values = [];

    public function __set(string $name, mixed $value): void
    {
        $this->values[$name] = $value;
    }

    public function __get(string $name): mixed
    {
        return $this->values[$name] ?? null;
    }

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

    public function __call(string $name, array $args): string
    {
        return $name . '(' . count($args) . ')';
    }

    public function __toString(): string
    {
        return 'Setting[' . count($this->values) . ']';
    }
}
```

```php
<?php
// setting_usage.php — magic methods are triggered by the expressions the consumer writes
require 'Setting.php';

$setting = new Setting();
$setting->theme = 'dark';
echo $setting->theme, "\n";
var_dump(isset($setting->theme), isset($setting->lang));
echo $setting->save(1, 2), "\n";
echo $setting, "\n";
```

```
dark
bool(true)
bool(false)
save(2)
Setting[1]
```

**OM22.** No property named `theme` was defined, and the consumer could still write and read it: `__set` put the value into the `$values` array and `__get` took it back out. There was no `save()` method either; `__call` returned the name and the number of arguments. This differs from the dynamic property in the Classes and Objects lesson: there the property was created on the object and raised a deprecation notice (`E_DEPRECATED`), while here a defined method answers.

## __unset and __callStatic

`__unset` runs when an inaccessible property is removed with `unset()`; it is written together with `__isset`, because both look at the same data structure. `__callStatic` is the static counterpart of `__call`: it takes over when a method that is not in the class is called in the form `ClassName::name()`. Both pass the name and the arguments to the body of the method.

```php
<?php
// Record.php — a class that catches removal and static calls
class Record
{
    private array $data = ['a' => 1, 'b' => 2];

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

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

    public static function __callStatic(string $name, array $args): string
    {
        return 'static ' . $name . '(' . count($args) . ')';
    }
}
```

```php
<?php
// record_usage.php — unset and a static call go to magic methods
require 'Record.php';

$record = new Record();
var_dump(isset($record->a));
unset($record->a);
var_dump(isset($record->a));
echo Record::find(7), "\n";
```

```
bool(true)
bool(false)
static find(1)
```

Because `$data` is private, `$record->a` was inaccessible from outside; `isset` and `unset` therefore fell to the magic methods and removed the key in the array. Had `__unset` not been written, `unset($record->a)` would have passed without doing anything.

## A Magic Method Runs Only for an Inaccessible Name

A magic read method is not a shortcut: PHP first looks for the name among the real properties of the class and, if it finds it, never calls the method. `__get` runs only if the name does not exist or is not visible from the calling place. This distinction determines how moving a `public` property to a magic method later affects the consumer.

```php
<?php
// Vault.php — __get does not run while a real property exists
class Vault
{
    public string $open = 'o';
    private string $hidden = 'h';

    public function __get(string $name): string
    {
        return 'magic:' . $name;
    }
}
```

```php
<?php
// vault_usage.php — __get runs only for an inaccessible name
require 'Vault.php';

$vault = new Vault();
echo $vault->open, ' ', $vault->hidden, ' ', $vault->missing, "\n";

unset($vault->open);
echo $vault->open, "\n";
```

```
o magic:hidden magic:missing
magic:open
```

`open` was real and visible, so it did not reach the magic method; `hidden` did because it is not visible from outside, and `missing` because it never existed. After `open` was removed, its name could no longer be found as a real property and `__get` took over. This behavior shows that a magic method can silently take the place of a real property.

## __toString and Stringable

`__toString` runs when an object enters a string context: `echo`, concatenation, a function parameter that expects a string. In PHP 8, every class that defines this method is counted as having implemented the `Stringable` interface on its own; the interface declaration is not needed. If an object without the method enters a string context, an `Error` is raised.

```php
<?php
// Tag.php — __toString and Stringable
class Tag
{
    public function __construct(private string $name)
    {
    }

    public function __toString(): string
    {
        return '<' . $this->name . '>';
    }
}

class Plain
{
}

$tag = new Tag('b');
echo $tag, ' ', strlen($tag), ' ', $tag instanceof Stringable ? 'yes' : 'no', "\n";
echo 'Text: ' . $tag . "\n";

try {
    echo new Plain();
} catch (Error $e) {
    echo get_class($e), "\n";
}
```

```
<b> 3 yes
Text: <b>
Error
```

`strlen()` converted the object to a string and counted `3` on `<b>`. Although the `Stringable` declaration was written nowhere, `instanceof` came out true. Adding `__toString` to a class lets that class's objects into every place that expects a string; this convenience means that the class alone decides the form of the conversion, with no say for the consumer.

## __set and Typos

When `__set` accepts every unknown name, code that misspells a name gets no error. The misspelled name becomes a new key in the array, while the correct name stays at its old value. If `?? null` is left on the read side, reading a wrong name also silently gives `null`.

```php
<?php
// Flexible.php — __set and __get that accept every name
class Flexible
{
    public string $title = '';
    private array $extra = [];

    public function __set(string $name, mixed $value): void
    {
        $this->extra[$name] = $value;
    }

    public function __get(string $name): mixed
    {
        return $this->extra[$name] ?? null;
    }
}

$flexible = new Flexible();
$flexible->title = 'first';
$flexible->titl = 'second';
echo $flexible->title, ' ', $flexible->titl, "\n";
var_dump($flexible->titlee);
```

```
first second
NULL
```

`titl` was a typo and `__set` accepted it without question; `title` stayed at its first value. No error came when `titlee` was read either. If the party that writes the magic method does not limit the accepted names to a list, the consumer loses spelling checks.

## Forwarding with __call

`__call` serves a common purpose: forwarding to another object. The wrapping class passes every method it does not know on to the object inside. The consumer calls the wrapper, but the method of the inner object actually runs; a name that the inner object does not have either again raises an `Error`.

```php
<?php
// Wrapper.php — a class that forwards an unknown method to the inner object
class Wrapper
{
    public function __construct(private ArrayObject $inner)
    {
    }

    public function __call(string $name, array $args): mixed
    {
        return $this->inner->$name(...$args);
    }
}

$wrapper = new Wrapper(new ArrayObject([3, 1, 2]));
echo $wrapper->count(), "\n";

try {
    $wrapper->absent();
} catch (Error $e) {
    echo get_class($e), "\n";
}
```

```
3
Error
```

`count()` was not defined on the wrapper and was answered by `ArrayObject` through the forwarding. This forwarding hides the methods of the wrapped class from the declaration of the wrapper; which names work is learned not by reading the code but by running it.

## __invoke

`__invoke` allows an object to be called like a function. The object is called in the form `$object(argument)` and PHP runs the body of the method. For this reason the object is accepted as `callable`: it can be given as a callback to a function such as `array_map`. When a transformation that carries state is needed, this feature takes the properties of the object along with the call.

```php
<?php
// Multiplier.php — calling an object like a function
class Multiplier
{
    public function __construct(private int $factor)
    {
    }

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

$three = new Multiplier(3);
echo $three(4), ' ', implode(',', array_map($three, [1, 2, 3])), ' ', var_export(is_callable($three), true), "\n";
```

```
12 3,6,9 true
```

`$three(4)` became `__invoke(4)` directly; `array_map` called the object for each element of the array. Because the factor stays in the object, reusing the transformation with a different factor came down to creating a new object.

## The Measurement Core

The measurement in this lesson also uses the `reach.php` file; the core was explained line by line in the first lesson.

```php
<?php
// reach.php — helpers that measure where a change reaches

function write_files(string $dir, array $files): void
{
    foreach ($files as $name => $content) {
        if ($content === null) {
            continue; // null: the file was deleted
        }
        $path = "$dir/$name";
        if (!is_dir(dirname($path))) {
            mkdir(dirname($path), 0777, true);
        }
        file_put_contents($path, $content);
    }
}

function read_files(string $dir): array
{
    $files = [];
    $walker = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($walker as $file) {
        $name = substr($file->getPathname(), strlen($dir) + 1);
        $files[$name] = file_get_contents($file->getPathname());
    }
    return $files;
}

function run(string $dir): array
{
    $command = 'cd ' . escapeshellarg($dir) . ' && php -d display_errors=0 main.php 2>/dev/null';
    exec($command, $lines, $code);
    return [$code, implode("\n", $lines)];
}

function measure(string $name, array $base, array $change, array $seams = []): string
{
    if (array_key_exists('main.php', $change)) {
        throw new LogicException("$name: the consumer is not edited");
    }
    write_files("measure/$name/before", $base);
    write_files("measure/$name/after", array_merge($base, $change));
    [$codeBefore, $outputBefore] = run("measure/$name/before");
    [$codeAfter, $outputAfter] = run("measure/$name/after");
    if ($codeBefore !== 0) {
        throw new LogicException("$name: the baseline did not run");
    }
    $reach = match (true) {
        $codeAfter !== 0 => 'breaking',
        $outputBefore !== $outputAfter => 'implicit',
        array_intersect(array_keys($change), $seams) !== [] => 'at-seam',
        default => 'in-place',
    };
    printf(
        "%-18s files=%d  exit=%-3d output %-7s -> %s\n",
        $name,
        count($change),
        $codeAfter,
        $outputBefore === $outputAfter ? 'same' : 'changed',
        $reach
    );
    return $reach;
}
```

## The Internal Name Changes

The measured change is renaming the `theme` key in the internal array of the `Setting` class to `display`. `__get` and no other part of the class changes. The consumer `main.php` prints the value of `(new Setting())->theme` with `var_dump`.

```php
<?php
// magic_read.php — the internal key that __get looks at is renamed
require_once 'reach.php';

measure('magic-read', [
    'setting.php' => <<<'PHP'
        <?php
        class Setting
        {
            private array $values = ['theme' => 'dark'];
            public function __get(string $name): mixed { return $this->values[$name] ?? null; }
        }
        PHP,
    'main.php' => <<<'PHP'
        <?php
        require 'setting.php';
        var_dump((new Setting())->theme);
        PHP,
], [
    'setting.php' => <<<'PHP'
        <?php
        class Setting
        {
            private array $values = ['display' => 'dark'];
            public function __get(string $name): mixed { return $this->values[$name] ?? null; }
        }
        PHP,
]);

foreach (['before', 'after'] as $stage) {
    [$code, $output] = run("measure/magic-read/$stage");
    echo $stage, ' exit=', $code, ' output=', $output, "\n";
}
```

```
magic-read         files=1  exit=0   output changed -> implicit
before exit=0 output=string(4) "dark"
after exit=0 output=NULL
```

**OM23.** The consumer saw nothing: the exit code stayed 0, but `NULL` came in place of `string(4) "dark"`. `__get` answered an unknown name with `null`, so `theme` no longer existed and nothing said so. The lesson's first reach is **implicit**. Because a magic method turns reading from a contract into a lookup, the rename breaks no signature.

## The Bounding Measurement

Is the silence caused by the rename, or by `__get` answering an unknown name with `null`? The second option is measured: the same rename is made, but this time `__get` throws an `OutOfRangeException` for an unknown name.

```php
<?php
// unknown_name.php — the same rename, this time __get rejects the unknown name
require_once 'reach.php';

measure('unknown-name', [
    'setting.php' => <<<'PHP'
        <?php
        class Setting
        {
            private array $values = ['theme' => 'dark'];
            public function __get(string $name): mixed
            {
                if (!array_key_exists($name, $this->values)) {
                    throw new OutOfRangeException($name);
                }
                return $this->values[$name];
            }
        }
        PHP,
    'main.php' => <<<'PHP'
        <?php
        require 'setting.php';
        var_dump((new Setting())->theme);
        PHP,
], [
    'setting.php' => <<<'PHP'
        <?php
        class Setting
        {
            private array $values = ['display' => 'dark'];
            public function __get(string $name): mixed
            {
                if (!array_key_exists($name, $this->values)) {
                    throw new OutOfRangeException($name);
                }
                return $this->values[$name];
            }
        }
        PHP,
]);

foreach (['before', 'after'] as $stage) {
    [$code, $output] = run("measure/unknown-name/$stage");
    echo $stage, ' exit=', $code, ' output=', $output, "\n";
}
```

```
unknown-name       files=1  exit=255 output changed -> breaking
before exit=0 output=string(4) "dark"
after exit=255 output=
```

**OM24.** The same rename, the same consumer; but the exit code became 255, because the name `theme` was no longer known and `__get` reported this with an exception. What produced the silence was not the rename but `__get` brushing the unknown name aside. The answer of a magic method is the decision of the class author: the same change can be implicit with `null` and breaking with an exception.

## Summary

- Magic methods such as `__get`, `__set`, `__isset`, `__call`, and `__toString` are methods whose names begin with two underscores and which PHP itself calls.
- Once reading, writing, and method calls are turned into magic methods, an undefined name does not stop the consumer.
- When the internal key that `__get` looks at went from `theme` to `display`, the consumer got no error, with exit code 0; `NULL` came in place of `string(4) "dark"`, and the lesson's reach is implicit.
- When `__get` threw an `OutOfRangeException` for an unknown name, the same change became breaking, with exit code 255.
- Whether the same change is implicit or breaking is decided by how the magic method receives an unknown name.

## Next Step

Magic methods put a loose translator in place; type declarations do the opposite and place a parameter, a return, and a property type at the boundary of the class. The Type Declarations lesson asks which inputs stop the consumer when `int` is written on the parameter of a method.
