Magic Methods: Construction, Properties & Calls

Master the lifecycle and property/method-interception magic methods — __construct, __destruct, __get, __set, __isset, __unset, __call, __callStatic — and learn concrete guidance on when they help versus when they hurt debuggability and performance.

Magic methods are special, double-underscore-prefixed methods PHP calls automatically in response to specific events: object construction and destruction, access to undefined or inaccessible properties, and calls to undefined or inaccessible methods. They let a class intercept operations that would normally be errors and handle them dynamically instead. Used well, they enable elegant APIs (lazy-loaded properties, fluent method-missing proxies); used carelessly, they make code that looks simple hide arbitrarily complex, hard-to-trace behavior — this lesson covers both the mechanics and the judgment calls.

__construct and __destruct: the object lifecycle

__construct() runs automatically when new ClassName(...) is called, and is where you initialize an object's required state — typically via constructor property promotion (public function __construct(private string $name) {}) or explicit assignment. If a class defines no constructor, PHP simply creates the object with only its default property values; if a subclass defines its own __construct() without calling parent::__construct(), the parent's constructor logic does NOT run automatically — you must call it explicitly, which is a common source of subtle initialization bugs in inheritance hierarchies.

__destruct() runs when an object is about to be freed — normally when its last reference goes out of scope and PHP's reference-counting garbage collector reclaims it, or at script shutdown for objects still alive at that point. Destructors are useful for releasing external resources deterministically-ish (closing a file handle, a database connection you opened yourself) but PHP does NOT guarantee a precise, deterministic destruction order for objects involved in circular references — those are cleaned up by the cycle collector at a less predictable time. For truly guaranteed cleanup timing, prefer explicit close()/disconnect() methods (or the RAII-like pattern of a try/finally block) over relying on __destruct().

A critical safety rule: never let __destruct() throw an exception that escapes it. An uncaught exception thrown from a destructor during normal script execution becomes a fatal error, and if it happens during another exception's unwinding, or at shutdown, the failure mode is confusing and hard to debug. Wrap risky cleanup in try/catch inside the destructor and, at most, log failures.

__get, __set, __isset, __unset: intercepting property access

These four magic methods only fire for properties that are inaccessible in the current context — either genuinely undeclared, or declared private/protected and accessed from outside the class. They never fire for a public property that exists, so __get is not a general-purpose 'every property read' hook. public function __get(string $name) is called on read of an inaccessible property and must return a value; public function __set(string $name, $value): void is called on write; public function __isset(string $name): bool backs isset()/empty() checks against inaccessible properties; public function __unset(string $name): void backs unset().

The classic legitimate use is a lazy-loading or computed-property facade: an ORM entity that exposes $user->profile and only queries the database for the related Profile row the first time it's accessed, caching the result on a private backing array afterward. Another is a strict, validated property bag where __set rejects unknown keys or type-checks values before storing them in an internal array, giving you dynamic-looking properties with real validation behind them.

The recurring hazard: because __get/__set intercept typos too (accessing $user->naem silently calls __get('naem') instead of erroring, unless you explicitly throw inside it for unknown names), they can turn what should be an immediate fatal error into a confusing runtime bug that surfaces far from its cause. Always make __get/__set throw (typically \Error or a custom exception) for names they don't recognize, rather than silently returning null or swallowing the write.

__call and __callStatic: intercepting method calls

__call(string $name, array $arguments) fires when code calls an instance method that doesn't exist (or isn't accessible) on the object; __callStatic(string $name, array $arguments) is its static-context counterpart, firing for undefined static calls. Both receive the attempted method name as a string and its arguments as an array, and must return whatever the caller expects back.

The most common legitimate use is a fluent proxy or decorator — a class that wraps another object and forwards unknown method calls to it (return $this->wrapped->$name(...$arguments);), letting the wrapper transparently support the wrapped object's full API without manually redeclaring every method. Another common use is generated-style accessors, like a mock/stub object in a testing library that turns $mock->expectsCallTo('save') style method names into structured behavior, or an ActiveRecord-style ORM base class supporting dynamic finder methods like User::findByEmail($email) by parsing findBy + a property name out of the method name string.

Because __call/__callStatic accept literally any method name, they defeat IDE autocompletion and static analysis unless you add PHPDoc @method annotations on the class describing the 'virtual' methods it supports — a discipline worth adopting any time you use these, so tooling and other developers can still discover the real API.

Safe usage: when NOT to reach for magic methods

Magic methods trade explicitness for flexibility, and that trade is not free. Performance-wise, __get/__set/__call are meaningfully slower than direct property/method access — PHP has to fail normal resolution first, then dispatch to your magic method, which involves extra function-call overhead on every single access. In a loop touching thousands of magic properties, this is measurable; for typical web request volumes it's rarely the bottleneck, but it is not free the way normal property access is.

Debuggability-wise, magic methods hide the real code path: stepping through a debugger, $obj->name looks identical whether it's a plain public property or triggers ten lines of __get logic including a database query, until you actually step into it. Static analysis tools (PHPStan, Psalm) also cannot verify magic-method-backed properties/methods exist or are correctly typed without explicit @property/@method PHPDoc annotations, silently reducing the safety net those tools normally provide.

A practical rule of thumb: prefer explicit typed properties and named methods for anything that is part of a class's normal, everyday API. Reserve __get/__set/__call/__callStatic for genuinely dynamic scenarios where the set of properties/methods truly cannot be known at write time — proxies/decorators, lazy-loaded relations in an ORM, and backward-compatibility shims for renamed properties — and always add PHPDoc @property/@method annotations so tooling and readers aren't left guessing.

• Prefer explicit properties/methods by default; reach for magic methods only when the member set is genuinely dynamic. • Always validate/throw on unknown names inside __get/__set/__call rather than silently accepting anything — fail loudly, not quietly. • Document magic-method-backed members with PHPDoc @property and @method tags so IDEs and static analyzers can still reason about the class. • Never let __destruct() allow an exception to escape; wrap risky cleanup and log failures instead.

Construction/property/call magic method signatures

__get/__set/__isset/__unset only fire for properties that are undeclared, or declared private/protected and accessed from outside the class — never for accessible public properties. __call/__callStatic only fire when normal method resolution fails to find a matching, accessible method — they are a fallback, not an interceptor for existing methods.

class Example
{
    public function __construct(/* args */) {}
    public function __destruct() {}

    public function __get(string $name): mixed {}
    public function __set(string $name, mixed $value): void {}
    public function __isset(string $name): bool {}
    public function __unset(string $name): void {}

    public function __call(string $name, array $arguments): mixed {}
    public static function __callStatic(string $name, array $arguments): mixed {}
}

Code Examples

The constructor acquires a resource (opens a file) and fails fast with a clear exception if that fails; the destructor releases it automatically once the object is no longer referenced, so callers don't have to remember to call a manual close() in the common case.

This validated property bag throws immediately on an unrecognized key in both __get and __set, so a typo like naem fails loudly at the point of the mistake instead of silently creating a phantom property or returning null — the crucial safe-usage discipline for magic property access. Expected result: bool(true) Property 'naem' is not allowed on StrictBag

__callStatic parses the intent out of the method name itself ('findBy' + a column name) and builds a parameterized query dynamically, avoiding a hand-written finder method for every column. The @method PHPDoc annotations above the class restore IDE autocomplete and static-analysis awareness for the specific dynamic methods this class is documented to support.

$column = lcfirst(substr($name, 6));

Common mistake: Falling through without throwing BadMethodCallException for unrecognized names would silently return an undefined value instead of a clear error for a genuine typo or unsupported call.

class ResourceHandle
{
    private $handle;

    public function __construct(string $path)
    {
        $this->handle = fopen($path, 'r');
        if ($this->handle === false) {
            throw new RuntimeException("Could not open {$path}");
        }
    }

    public function __destruct()
    {
        if (is_resource($this->handle)) {
            fclose($this->handle);
        }
    }
}

function readFirstLine(string $path): string {
    $h = new ResourceHandle($path);
    // ... use $h ...
    return 'done';
} // $h goes out of scope here; __destruct() runs, closing the file handle
final class StrictBag
{
    private array $data = [];
    private array $allowedKeys;

    public function __construct(array $allowedKeys)
    {
        $this->allowedKeys = array_flip($allowedKeys);
    }

    public function __get(string $name): mixed
    {
        if (!array_key_exists($name, $this->data)) {
            throw new \Error("Undefined or unset property: {$name}");
        }
        return $this->data[$name];
    }

    public function __set(string $name, mixed $value): void
    {
        if (!isset($this->allowedKeys[$name])) {
            throw new \Error("Property '{$name}' is not allowed on " . static::class);
        }
        $this->data[$name] = $value;
    }

    public function __isset(string $name): bool
    {
        return array_key_exists($name, $this->data);
    }

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

$bag = new StrictBag(['name', 'email']);
$bag->name = 'Ana';          // OK
var_dump(isset($bag->name)); // true
try {
    $bag->naem = 'typo';      // throws Error: not allowed
} catch (\Error $e) {
    echo $e->getMessage();
}
/**
 * @method static array findByEmail(string $email)
 * @method static array findByStatus(string $status)
 */
class ActiveRecordBase
{
    protected static PDO $connection;
    protected static string $table;

    public static function __callStatic(string $name, array $arguments): array
    {
        if (str_starts_with($name, 'findBy')) {
            $column = lcfirst(substr($name, 6));       // 'findByEmail' -> 'email'
            $stmt = static::$connection->prepare(
                "SELECT * FROM " . static::$table . " WHERE {$column} = ?"
            );
            $stmt->execute([$arguments[0]]);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        }

        throw new \BadMethodCallException("Undefined method: {$name}");
    }
}

class User extends ActiveRecordBase
{
    protected static string $table = 'users';
}

// User::findByEmail('[email protected]') dynamically becomes:
// SELECT * FROM users WHERE email = ? with '[email protected]' bound

When property/method magic fires

Magic property/method methods are strictly fallbacks — they only run when normal, direct resolution fails to find an accessible member.

$obj->prop access
        |
        v
  Is 'prop' a declared, accessible property? --yes--> normal access, NO magic method called
        | no
        v
  __get('prop') is called instead

  $obj->method() call
        |
        v
  Does an accessible method('method') exist? --yes--> normal call, NO magic method called
        | no
        v
  __call('method', $args) is called instead

Important Rules

__construct runs on new; a subclass constructor must call parent::__construct() explicitly if it needs the parent's initialization logic — it is not automatic.

Never let an exception escape __destruct(); catch and log inside it instead, since destruction timing (especially for cyclic references) is not fully deterministic.

__get/__set/__isset/__unset fire ONLY for inaccessible (undeclared, or private/protected-from-outside) properties — never for a normal accessible public property.

__call/__callStatic fire ONLY when normal method resolution finds nothing accessible — they are the last resort, not an override.

Magic methods should throw on unrecognized names rather than silently returning null/doing nothing, so typos fail loudly at their source.

Where this shows up

Doctrine and Eloquent ORMs use __get/__set to expose database columns and lazily-loaded relationships as though they were plain properties, only issuing the underlying SQL query the first time a relation is actually touched. Mocking libraries (PHPUnit's, Mockery's) use __call to intercept arbitrary method calls on a generated mock object and match them against configured expectations. Legacy compatibility shims use __get/__set to keep old property names working (with a deprecation notice) after an internal rename, without breaking existing calling code.

public function __get(string $name) { return $this->data[$name] ?? null; // silently returns null for any typo } public function __get(string $name) { if (!array_key_exists($name, $this->data)) { throw new \Error("Undefined property: {$name}"); } return $this->data[$name]; }

Silently returning null for an unrecognized property name turns a typo into a hard-to-trace bug far downstream (a null where a value was expected) instead of an immediate, clear error pointing straight at the mistake.

[multiple-choice] When does __get() get called on an object? A. Every time any property is read, including public properties B. Only when reading a property that is undeclared, or declared private/protected and accessed from outside the class C. Only inside the class's own methods D. Only when the property does not have a default value

B. Only when reading a property that is undeclared, or declared private/protected and accessed from outside the class — __get is strictly a fallback for inaccessible property reads. A normal, accessible public property is read directly and never triggers __get.

[multiple-choice] What is the safest practice inside __get/__set for an unrecognized property name? A. Silently return null (for __get) or silently ignore the write (for __set) B. Throw an exception/Error so the mistake surfaces immediately at its source C. Log a warning but continue as if nothing happened D. Automatically create the property with a default value

B. Throw an exception/Error so the mistake surfaces immediately at its source — Throwing on unrecognized names turns typos and genuine mistakes into immediate, traceable errors instead of silent nulls or lost writes that cause confusing bugs far from their actual cause.

[predict-output] Class Sub extends Base; both declare __construct(); Sub's constructor does NOT call parent::__construct(). What happens to Base's constructor logic when new Sub() runs? A. It runs automatically before Sub's own constructor body B. It runs automatically after Sub's own constructor body C. It does not run at all unless Sub's constructor explicitly calls parent::__construct() D. PHP throws a fatal error because parent::__construct() was not called

C. It does not run at all unless Sub's constructor explicitly calls parent::__construct() — Unlike some other languages, PHP does not automatically call a parent constructor when a child class defines its own __construct(). It must be called explicitly with parent::__construct(...), or the parent's initialization logic simply never runs.

Build a class LazyConfig that takes an array of key => callable loaders in its constructor. Accessing $config->someKey should call the corresponding loader function on first access, cache the returned value, and return the cached value on every subsequent access without calling the loader again. Accessing an unconfigured key should throw.

class LazyConfig { private array $resolved = []; public function __construct(private array $loaders) {} public function __get(string $name): mixed { if (array_key_exists($name, $this->resolved)) { return $this->resolved[$name]; } if (!isset($this->loaders[$name])) { throw new \Error("No loader configured for '{$name}'"); } return $this->resolved[$name] = ($this->loaders[$name])(); } }

Store the loaders and a separate 'resolved values' array as private properties. In __get, check the resolved cache first; if absent, call the loader, store its result, then return it.