PHP Magic Methods Explained: __get, __set, __call and Beyond
Every serious PHP codebase you'll encounter in the wild — Laravel, Symfony, Doctrine — leans heavily on magic methods. They're the invisible scaffolding behind Eloquent's $user->name syntax, behind dependency injection containers, behind fluent query builders that chain methods like words in a sentence. If you've ever wondered how a library seems to 'just know' what you want even when you haven't explicitly defined it, magic methods are almost always the answer.
The problem magic methods solve is rigidity. Without them, every property and method on a class must be hardcoded upfront. That's fine for simple models, but it falls apart the moment you need dynamic behavior — think configuration objects that load keys from a database, API wrappers that map any method call to an HTTP endpoint, or ORMs that let you access database columns as if they were plain PHP properties. Magic methods are PHP's hook system for object behavior: they fire automatically when specific actions happen to your object.
By the end of this article you'll understand not just what each magic method does, but exactly when and why to reach for it. You'll be able to read Laravel source code and understand what's happening under the hood, implement a clean property-access layer for your own models, debug the 'why is this method being called?' confusion that catches even experienced developers off guard, and answer magic method questions confidently in a technical interview.
__construct and __destruct: The Object's Birth and Death
__construct is the first magic method most developers meet, but many treat it as 'just where you put initialization code' without understanding its real power. It fires the moment you call new ClassName(), before your code can do anything else with the object. That guarantee is the whole point — you can enforce that an object is always born in a valid state.
__destruct is its mirror image: it fires when the object is about to be destroyed, either because it went out of scope, you called unset() on it, or the script is ending. Most developers ignore __destruct entirely, and that's usually fine — but it's invaluable when your object holds an external resource like a file handle, a database connection, or a locked mutex. Instead of remembering to close the resource manually, you bake the cleanup into the object itself.
Think of __construct as a checklist a surgeon runs before an operation, and __destruct as the cleanup procedure after. The patient (your object) can't be used until the pre-op check passes, and the room gets cleaned automatically when the operation ends — you don't have to remember to do it.
<?php class CsvFileWriter { private $fileHandle; // Holds the open file resource private string $filePath; private int $rowsWritten = 0; public function __construct(string $filePath, string $mode = 'w') { // Validate the directory exists before we even try to open the file. // This enforces a valid state at birth — no half-constructed objects. $directory = dirname($filePath); if (!is_dir($directory)) { throw new InvalidArgumentException( "Directory '{$directory}' does not exist." ); } $this->filePath = $filePath; $this->fileHandle = fopen($filePath, $mode); if ($this->fileHandle === false) { throw new RuntimeException("Could not open file: {$filePath}"); } echo "[__construct] File opened: {$filePath}" . PHP_EOL; } public function writeRow(array $columns): void { // fputcsv handles quoting and escaping automatically fputcsv($this->fileHandle, $columns); $this->rowsWritten++; } public function __destruct() { // Even if the developer forgets to close the file, // this cleanup runs automatically when the object is destroyed. if (is_resource($this->fileHandle)) { fclose($this->fileHandle); echo "[__destruct] File closed after writing {$this->rowsWritten} row(s): {$this->filePath}" . PHP_EOL; } } } // --- Usage --- function exportUserReport(string $outputPath): void { $writer = new CsvFileWriter($outputPath); $writer->writeRow(['id', 'name', 'email']); $writer->writeRow([1, 'Alice Johnson', 'alice@example.com']); $writer->writeRow([2, 'Bob Smith', 'bob@example.com']); // No manual fclose() needed — __destruct handles it // when $writer goes out of scope at the end of this function. } exportUserReport('/tmp/users_report.csv'); echo "Function has returned — object is already cleaned up." . PHP_EOL;
[__destruct] File closed after writing 3 row(s): /tmp/users_report.csv
Function has returned — object is already cleaned up.
__get, __set, __isset and __unset: Building a Dynamic Property Layer
Here's the scenario: you have a configuration object, or a database model, or an API response wrapper. The actual data lives in a private array, but you want developers to access it like a normal property — $config->debug instead of $config->get('debug'). That's exactly what __get and __set enable.
__get($name) fires when code tries to read a property that either doesn't exist or isn't accessible (private/protected). __set($name, $value) fires when code tries to write to such a property. Their siblings, __isset($name) and __unset($name), fire when isset() or unset() are called on those inaccessible properties — and this is where most developers trip up. If you implement __get and __set but forget __isset, then isset($config->debug) will silently return false even when the value exists, which breaks if() guards and null coalescing operators all over your codebase.
These four methods together form a complete property interception layer. Laravel's Eloquent model is the most famous real-world example — every column in your database table becomes an accessible property without any explicit declaration, because __get and __set intercept the access and proxy it to an internal attributes array.
<?php /** * Wraps an API JSON response so callers can access fields as properties * instead of navigating raw arrays: $response->userId vs $response->data['user_id'] */ class ApiResponseWrapper { // The raw decoded payload lives here — hidden from the outside world private array $payload; // Track which fields were accessed, useful for debugging / analytics private array $accessLog = []; public function __construct(array $jsonPayload) { $this->payload = $jsonPayload; } /** * Fires when you read a property that doesn't exist on this class directly. * Converts camelCase property names to snake_case to match typical JSON keys. */ public function __get(string $propertyName): mixed { $snakeKey = $this->toSnakeCase($propertyName); $this->accessLog[] = $snakeKey; if (!array_key_exists($snakeKey, $this->payload)) { // Return null gracefully rather than throwing — caller can check with isset() return null; } return $this->payload[$snakeKey]; } /** * Fires when you try to write a property that isn't explicitly declared. * We store the update in the payload so it's round-trippable. */ public function __set(string $propertyName, mixed $value): void { $snakeKey = $this->toSnakeCase($propertyName); $this->payload[$snakeKey] = $value; } /** * CRITICAL: Without this, isset($response->userId) always returns false * even when the key exists in the payload. This breaks null-coalescing * operators and if(isset()) guards throughout your codebase. */ public function __isset(string $propertyName): bool { $snakeKey = $this->toSnakeCase($propertyName); return isset($this->payload[$snakeKey]); } /** * Allows unset($response->userId) to actually remove the key from the payload. */ public function __unset(string $propertyName): void { $snakeKey = $this->toSnakeCase($propertyName); unset($this->payload[$snakeKey]); } public function getAccessLog(): array { return $this->accessLog; } // Converts 'userId' -> 'user_id', 'createdAt' -> 'created_at' private function toSnakeCase(string $camelCase): string { return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($camelCase))); } } // --- Usage --- // Simulating a decoded JSON payload from a REST API $rawPayload = [ 'user_id' => 42, 'first_name' => 'Alice', 'created_at' => '2024-03-15', 'is_active' => true, ]; $response = new ApiResponseWrapper($rawPayload); // Read with camelCase — __get converts it to snake_case internally echo "User ID: " . $response->userId . PHP_EOL; echo "First Name: " . $response->firstName . PHP_EOL; // __isset makes null-coalescing work correctly $status = isset($response->isActive) ? 'Active' : 'Inactive'; echo "Status: {$status}" . PHP_EOL; // Reading a field that doesn't exist returns null gracefully $missing = $response->phoneNumber ?? 'N/A'; echo "Phone: {$missing}" . PHP_EOL; // Write via __set $response->lastLoginAt = '2024-11-01'; echo "Last Login: " . $response->lastLoginAt . PHP_EOL; // Inspect what was accessed (useful for caching / performance profiling) echo "Fields accessed: " . implode(', ', $response->getAccessLog()) . PHP_EOL;
First Name: Alice
Status: Active
Phone: N/A
Last Login: 2024-11-01
Fields accessed: user_id, first_name, is_active, phone_number, last_login_at
__call and __callStatic: Intercepting Unknown Method Calls
__call($methodName, $arguments) fires when you call a method on an object that doesn't exist or isn't accessible. __callStatic($methodName, $arguments) does the same for static calls. These two are the engine behind some of the most elegant API designs in modern PHP.
The classic real-world use case is a fluent query builder or an HTTP client where you want to support dozens of 'filter' methods without defining each one explicitly. Instead of writing filterByEmail(), filterByName(), filterByCreatedAt() as separate methods, you define __call once, inspect the method name, and generate the query clause dynamically. Laravel's where() magic methods work exactly this way — whereEmail('alice@example.com') is handled by __call, which parses 'Email' out of the method name and builds a WHERE clause.
Another killer use case is method proxying — when your class wraps another object and you want to transparently forward unknown method calls to the wrapped object. This is the Decorator pattern made effortless.
__callStatic is particularly useful for factory methods: ClassName::fromArray(), ClassName::fromJson() can all be handled by a single __callStatic that dispatches based on the method name, keeping your class interface clean without dozens of static factory methods cluttering the definition.
<?php /** * A simplified fluent query builder that uses __call to handle * dynamic 'whereXxx' and 'orderByXxx' methods without defining each one. */ class FluentQueryBuilder { private string $table; private array $conditions = []; private array $ordering = []; private ?int $limitCount = null; public function __construct(string $table) { $this->table = $table; } /** * Named constructor — __callStatic intercepts FluentQueryBuilder::for('users') * even though no static method called 'for' is defined. */ public static function __callStatic(string $methodName, array $arguments): static { // We only support the 'for' factory method via __callStatic if ($methodName === 'for') { $tableName = $arguments[0] ?? throw new InvalidArgumentException('Table name required.'); return new static($tableName); // Returns a fresh builder instance } throw new BadMethodCallException("Static method '{$methodName}' is not supported."); } /** * Handles: * ->whereEmail('alice@example.com') => WHERE email = 'alice@example.com' * ->whereIsActive(true) => WHERE is_active = 1 * ->orderByCreatedAt('DESC') => ORDER BY created_at DESC * * $methodName = the full method name string, e.g. 'whereEmail' * $arguments = array of arguments passed to the call, e.g. ['alice@example.com'] */ public function __call(string $methodName, array $arguments): static { if (str_starts_with($methodName, 'where')) { // Extract 'Email' from 'whereEmail', convert to 'email' $columnName = $this->toSnakeCase(substr($methodName, 5)); $value = $arguments[0] ?? throw new InvalidArgumentException( "A value is required for {$methodName}()" ); // Store as a safe, typed condition $this->conditions[] = [ 'column' => $columnName, 'value' => $value, ]; return $this; // Return $this to enable chaining } if (str_starts_with($methodName, 'orderBy')) { $columnName = $this->toSnakeCase(substr($methodName, 7)); $direction = strtoupper($arguments[0] ?? 'ASC'); if (!in_array($direction, ['ASC', 'DESC'], true)) { throw new InvalidArgumentException("Direction must be ASC or DESC."); } $this->ordering[] = "{$columnName} {$direction}"; return $this; } // Anything we don't recognise should fail loudly, not silently throw new BadMethodCallException( "Method '{$methodName}' does not exist on " . static::class ); } public function limit(int $count): static { $this->limitCount = $count; return $this; } /** * Builds a SQL-like query string for demonstration. * In a real ORM this would prepare and execute a PDO statement. */ public function toSql(): string { $sql = "SELECT * FROM {$this->table}"; if (!empty($this->conditions)) { $whereClauses = array_map( fn($c) => "{$c['column']} = '" . addslashes((string) $c['value']) . "'", $this->conditions ); $sql .= " WHERE " . implode(' AND ', $whereClauses); } if (!empty($this->ordering)) { $sql .= " ORDER BY " . implode(', ', $this->ordering); } if ($this->limitCount !== null) { $sql .= " LIMIT {$this->limitCount}"; } return $sql; } } // --- Usage --- // __callStatic handles the ::for() factory call $query = FluentQueryBuilder::for('users') ->whereIsActive(true) // __call handles this ->whereSubscriptionTier('pro') // __call handles this too ->orderByCreatedAt('DESC') // and this ->limit(10); echo $query->toSql() . PHP_EOL; // Another example $adminQuery = FluentQueryBuilder::for('users') ->whereRole('admin') ->whereIsVerified(true) ->orderByLastLoginAt('DESC'); echo $adminQuery->toSql() . PHP_EOL;
SELECT * FROM users WHERE role = 'admin' AND is_verified = '1' ORDER BY last_login_at DESC
__toString, __invoke, and __clone: The Polished Finishing Touches
These three magic methods are about making your objects behave like first-class citizens of the PHP language, not just bags of data.
__toString lets your object define what it looks like when used in a string context — echo $order, (string) $user, or string interpolation "Hello {$user}". Without it, PHP throws a fatal 'Object of class X could not be converted to string.' With it, you control the narrative. A Money object might render as '$42.50', a DateRange as '2024-01-01 to 2024-03-31'.
__invoke is arguably the most underused magic method. It fires when you call an object as if it were a function: $myObject(). This lets you create callable objects — objects that carry state between calls, unlike anonymous functions defined inline. Think of a RateLimiter, a cached function wrapper, or a middleware handler. In modern PHP, this is also how you build objects that satisfy callable type hints cleanly.
__clone fires when you use the clone keyword to copy an object. PHP's default clone is a shallow copy — nested objects inside your object still point to the same instance. If your object holds a DateTime or a collection object, the clone shares that reference, meaning modifying the clone modifies the original too. __clone lets you implement a proper deep copy by cloning the inner objects as well.
<?php /** * A value object representing a monetary amount. * Demonstrates __toString, __invoke, and __clone working together. */ class MoneyValue { // Store as integer cents to avoid floating-point rounding errors private int $amountInCents; private string $currencyCode; private DateTime $createdAt; public function __construct(int $amountInCents, string $currencyCode = 'USD') { if ($amountInCents < 0) { throw new InvalidArgumentException('Monetary amount cannot be negative.'); } $this->amountInCents = $amountInCents; $this->currencyCode = strtoupper($currencyCode); $this->createdAt = new DateTime(); // Inner object — important for __clone demo } /** * Called automatically when the object is used in a string context. * Lets you do: echo $price; or "Total: {$price}" without any ->format() call. */ public function __toString(): string { $formatted = number_format($this->amountInCents / 100, 2); $symbol = match($this->currencyCode) { 'USD' => '$', 'EUR' => '€', 'GBP' => '£', default => $this->currencyCode . ' ', }; return "{$symbol}{$formatted}"; } /** * Called when the object is used as a function: $tax = $price(0.2) * Here it acts as a multiplier — useful as a transformer/callback. * This makes MoneyValue usable anywhere a callable is expected. */ public function __invoke(float $multiplier): static { $newAmountInCents = (int) round($this->amountInCents * $multiplier); return new static($newAmountInCents, $this->currencyCode); } /** * PHP's default clone would make $clone->createdAt point to the SAME * DateTime object as the original. Changing one would change both. * __clone ensures each clone gets its own independent DateTime instance. */ public function __clone() { // Deep-copy the inner DateTime object so the clone is truly independent $this->createdAt = clone $this->createdAt; } public function add(MoneyValue $other): static { if ($this->currencyCode !== $other->currencyCode) { throw new InvalidArgumentException('Cannot add different currencies.'); } return new static($this->amountInCents + $other->amountInCents, $this->currencyCode); } public function getCreatedAt(): DateTime { return $this->createdAt; } } // --- Demo: __toString --- $subtotal = new MoneyValue(4999, 'USD'); // $49.99 $shipping = new MoneyValue(599, 'USD'); // $5.99 $total = $subtotal->add($shipping); echo "Subtotal: {$subtotal}" . PHP_EOL; // __toString fires inside the string echo "Shipping: {$shipping}" . PHP_EOL; echo "Total: {$total}" . PHP_EOL; // --- Demo: __invoke --- // Apply 20% tax to the total — $total is called like a function $taxAmount = $total(0.20); echo "Tax (20%): {$taxAmount}" . PHP_EOL; // Works as a callable too — pass it to array_map, usort, etc. $prices = [new MoneyValue(1000), new MoneyValue(2500), new MoneyValue(500)]; $discounted = array_map(fn($p) => $p(0.9), $prices); // 10% off each echo "Discounted prices: " . implode(', ', $discounted) . PHP_EOL; // --- Demo: __clone --- $original = new MoneyValue(9999, 'GBP'); $copy = clone $original; // __clone fires here // Modify the clone's inner DateTime — should NOT affect the original $copy->getCreatedAt()->modify('+1 year'); echo "Original timestamp: " . $original->getCreatedAt()->format('Y') . PHP_EOL; echo "Clone timestamp: " . $copy->getCreatedAt()->format('Y') . PHP_EOL;
Shipping: $5.99
Total: $55.98
Tax (20%): $11.20
Discounted prices: $9.00, $22.50, $4.50
Original timestamp: 2024
Clone timestamp: 2025
| Magic Method | Trigger | Common Real-World Use |
|---|---|---|
| __construct | new ClassName() | Enforce valid initial state, inject dependencies |
| __destruct | Object goes out of scope or unset() called | Release file handles, DB connections, locks |
| __get($name) | Reading an inaccessible or non-existent property | ORM column access, config objects, API response wrappers |
| __set($name, $val) | Writing to an inaccessible or non-existent property | Storing dynamic properties in a backing array |
| __isset($name) | isset() or empty() on an inaccessible property | Making ?? and if(isset()) work with dynamic properties |
| __unset($name) | unset() on an inaccessible property | Removing keys from a backing store |
| __call($name, $args) | Calling a non-existent or inaccessible instance method | Dynamic query builders, method proxies, fluent APIs |
| __callStatic($name, $args) | Calling a non-existent static method | Factory methods, static DSLs |
| __toString() | Object used in string context | Rendering value objects, templates, logging |
| __invoke($args) | Object called as a function: $obj() | Stateful callables, middleware, cached transformers |
| __clone() | clone $object called | Deep-copying objects with nested object properties |
🎯 Key Takeaways
- __isset is always forgotten and always breaks things — any time you implement __get, also implement __isset or your null coalescing operators will silently return wrong values.
- __invoke makes objects callable — they satisfy the callable type hint, return true from is_callable(), and can carry state between calls unlike plain closures defined inline.
- PHP's default clone is a shallow copy — nested objects share the same reference. Implement __clone to deep-copy inner objects whenever your class holds references to other objects.
- __call should always throw a BadMethodCallException for unrecognised method names — silently swallowing unknown calls produces bugs that are nearly impossible to trace.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Implementing __get and __set but forgetting __isset — Symptom: isset($obj->dynamicProp) returns false even when the value exists, breaking null coalescing (??) and conditional checks silently. Fix: Always implement all four property interceptors (__get, __set, __isset, __unset) as a group — treat them as an inseparable quartet.
- ✕Mistake 2: Throwing exceptions inside __destruct — Symptom: A fatal error 'Exception thrown without a stack frame' is emitted, which completely masks the original exception that triggered the object's destruction. Fix: Wrap all __destruct logic in try/catch, log errors internally, and never let __destruct throw — it must fail silently to the outside world.
- ✕Mistake 3: Forgetting that __call does NOT fire for inaccessible (private/protected) methods on child classes — Symptom: A child class calls a parent's private method and expects __call to intercept it, but PHP throws a 'Call to private method' fatal error instead. Fix: Make interceptable methods protected, not private, or explicitly delegate via a public proxy method. __call only fires for truly non-existent or inaccessible-from-context methods, not visibility-blocked ones in the same class hierarchy.
Interview Questions on This Topic
- QWhat is the difference between __get and a regular public property? Why would you choose __get over simply declaring the property public?
- QIf you implement __call in a class, will it fire when you call a method that exists but is marked private? Explain what actually happens and why.
- QA teammate clones an object and then complains that modifying the clone is somehow changing the original object's data. What is the most likely cause, and how would you fix it using a magic method?
Frequently Asked Questions
How many magic methods does PHP have?
PHP has 17 magic methods as of PHP 8.x: __construct, __destruct, __call, __callStatic, __get, __set, __isset, __unset, __sleep, __wakeup, __serialize, __unserialize, __toString, __invoke, __set_state, __clone, and __debugInfo. The ones you'll use most in real applications are __construct, __get, __set, __isset, __call, __toString, __invoke, and __clone.
Does using __get and __set slow down my application?
Yes, slightly — magic method calls have a small overhead compared to direct property access because PHP has to check for a regular property first, fail, then dispatch to the magic method. In practice this is negligible for typical web applications, but in extremely tight loops processing thousands of objects you'd want to benchmark. For most CRUD-style applications, the design clarity far outweighs the micro-performance cost.
Can I call a magic method directly, like $obj->__get('name')?
You can call them directly as methods — $obj->__toString() is valid syntax — but you almost never should. Magic methods are designed to be triggered implicitly by PHP's engine, not called explicitly. Calling __toString() directly bypasses the (string) cast behavior and loses the implicit trigger semantics. The one practical exception is calling parent::__construct() from a child class constructor, which is both valid and the standard pattern.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.