Skip to content
academia.sh

Lesson 06 / 12

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.

Published Updated

In this lesson

  • 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
Contents

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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close