__construct enforces valid state at birth; __destruct cleans up resources automatically.
__toString, __invoke, __clone make objects behave like strings, functions, and support deep copying.
Performance: magic methods are ~2–3x slower than direct property access — negligible for most apps, but benchmark tight loops.
Production insight: forgetting __isset breaks null coalescing and if(isset()) silently — always implement it alongside __get.
Biggest mistake: treating magic methods as a shortcut instead of a design tool leads to unmaintainable code.
Plain-English First
Imagine you hire a personal assistant. When someone calls your office asking for something you don't have on your desk, the assistant intercepts the call and figures out what to do — maybe they grab it from a filing cabinet, maybe they politely say 'that doesn't exist.' PHP magic methods work exactly like that assistant. They intercept actions on your objects — accessing a property that doesn't exist, calling a method that isn't defined — and let YOU decide what happens instead of PHP throwing an error.
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.
CsvFileWriter.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php
classCsvFileWriter
{
private $fileHandle; // Holds the open file resourceprivate string $filePath;
private int $rowsWritten = 0;
publicfunction__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)) {
thrownewInvalidArgumentException(
"Directory '{$directory}' does not exist."
);
}
$this->filePath = $filePath;
$this->fileHandle = fopen($filePath, $mode);
if ($this->fileHandle === false) {
thrownewRuntimeException("Could not open file: {$filePath}");
}
echo"[__construct] File opened: {$filePath}" . PHP_EOL;
}
publicfunctionwriteRow(array $columns): void
{
// fputcsv handles quoting and escaping automaticallyfputcsv($this->fileHandle, $columns);
$this->rowsWritten++;
}
publicfunction__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 ---functionexportUserReport(string $outputPath): void
{
$writer = newCsvFileWriter($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;
Output
[__construct] File opened: /tmp/users_report.csv
[__destruct] File closed after writing 3 row(s): /tmp/users_report.csv
Function has returned — object is already cleaned up.
Watch Out: __destruct and Exceptions
If an exception is thrown inside __destruct, PHP emits a fatal error that completely swallows the original exception you were trying to handle. Always wrap __destruct logic in a try/catch and fail silently — log the error, but never let __destruct throw.
Production Insight
In a production API, if __destruct throws, the entire script halts with 'Fatal error: Uncaught Exception' and your monitoring gets a false alarm.
Always log errors inside __destruct, never rethrow.
Rule: __destruct must never throw — the caller can't catch it.
Key Takeaway
__construct enforces valid state at birth.
__destruct guarantees cleanup even if the developer forgets.
Never throw from __destruct.
__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.
ApiResponseWrapper.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<?php
/**
* Wraps an APIJSON response so callers can access fields as properties
* instead of navigating raw arrays: $response->userId vs $response->data['user_id']
*/
classApiResponseWrapper
{
// The raw decoded payload lives here — hidden from the outside worldprivatearray $payload;
// Track which fields were accessed, useful for debugging / analyticsprivatearray $accessLog = [];
publicfunction__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.
*/
publicfunction__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()returnnull;
}
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.
*/
publicfunction__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 andif(isset()) guards throughout your codebase.
*/
publicfunction__isset(string $propertyName): bool
{
$snakeKey = $this->toSnakeCase($propertyName);
returnisset($this->payload[$snakeKey]);
}
/**
* Allowsunset($response->userId) to actually remove the key from the payload.
*/
publicfunction__unset(string $propertyName): void
{
$snakeKey = $this->toSnakeCase($propertyName);
unset($this->payload[$snakeKey]);
}
publicfunctiongetAccessLog(): array
{
return $this->accessLog;
}
// Converts 'userId' -> 'user_id', 'createdAt' -> 'created_at'privatefunctiontoSnakeCase(string $camelCase): string
{
returnstrtolower(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 = newApiResponseWrapper($rawPayload);
// Read with camelCase — __get converts it to snake_case internallyecho"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;
Any time you implement __get, always implement __isset too. The null coalescing operator (??) and empty() both call __isset internally. Forget it and you'll spend hours debugging why $value ?? 'default' never returns the actual value — it always returns 'default' because isset() quietly returned false.
Production Insight
In a high-throughput system, __isset being missing caused a silent data loss bug: a config loader always fell back to defaults, ignoring valid keys.
The bug was found 5 months later during a migration — by then, thousands of wrong defaults had been stored.
Rule: treat __get, __set, __isset, __unset as a required quartet.
Key Takeaway
Always implement all four property interceptors together.
Missing __isset breaks null coalescing silently.
__isset is the least known but most critical of the quartet.
__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.
FluentQueryBuilder.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<?php
/**
* A simplified fluent query builder that uses __call to handle
* dynamic 'whereXxx'and'orderByXxx' methods without defining each one.
*/
classFluentQueryBuilder
{
private string $table;
privatearray $conditions = [];
privatearray $ordering = [];
private ?int $limitCount = null;
publicfunction__construct(string $table)
{
$this->table = $table;
}
/**
* Named constructor — __callStatic intercepts FluentQueryBuilder::for('users')
* even though no static method called 'for' is defined.
*/
publicstaticfunction__callStatic(string $methodName, array $arguments): static
{
// We only support the 'for' factory method via __callStaticif ($methodName === 'for') {
$tableName = $arguments[0] ?? thrownewInvalidArgumentException('Table name required.');
return new static($tableName); // Returns a fresh builder instance
}
thrownewBadMethodCallException("Static method '{$methodName}' is not supported.");
}
/**
* Handles:
* ->whereEmail('alice@example.com') => WHERE email = 'alice@example.com'
* ->whereIsActive(true) => WHERE is_active = 1
* ->orderByCreatedAt('DESC') => ORDERBY 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']
*/
publicfunction__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] ?? thrownewInvalidArgumentException(
"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)) {
thrownewInvalidArgumentException("Direction must be ASC or DESC.");
}
$this->ordering[] = "{$columnName} {$direction}";
return $this;
}
// Anything we don't recognise should fail loudly, not silentlythrownewBadMethodCallException(
"Method '{$methodName}' does not exist on " . static::class
);
}
publicfunctionlimit(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.
*/
publicfunctiontoSql(): 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;
Output
SELECT * FROM users WHERE is_active = '1' AND subscription_tier = 'pro' ORDER BY created_at DESC LIMIT 10
SELECT * FROM users WHERE role = 'admin' AND is_verified = '1' ORDER BY last_login_at DESC
Interview Gold: Why __call Returns $this
Returning $this from __call is what makes method chaining work. Each call in the chain receives the same object back, calls the next method on it, and returns $this again. Without that return, the chain breaks on the very next call with 'Call to a member function on null' — a classic debugging trap.
Production Insight
In a payment gateway wrapper, __call proxied to an HTTP client. A typo 'chargeAmout' instead of 'chargeAmount' silently passed to the API and got a 400 error.
The developer had to add method name logging in __call to trace the issue.
Rule: always log unknown method names in __call during development.
Key Takeaway
__call enables dynamic method dispatch without polluting class definitions.
Always return $this for fluent interfaces.
Throw BadMethodCallException for unrecognised methods.
__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.
MoneyValue.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<?php
/**
* A value object representing a monetary amount.
* Demonstrates __toString, __invoke, and __clone working together.
*/
classMoneyValue
{
// Store as integer cents to avoid floating-point rounding errorsprivate int $amountInCents;
private string $currencyCode;
privateDateTime $createdAt;
publicfunction__construct(int $amountInCents, string $currencyCode = 'USD')
{
if ($amountInCents < 0) {
thrownewInvalidArgumentException('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.
*/
publicfunction__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.
*/
publicfunction__invoke(float $multiplier): static
{
$newAmountInCents = (int) round($this->amountInCents * $multiplier);
returnnewstatic($newAmountInCents, $this->currencyCode);
}
/**
* PHP's defaultclone 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.
*/
publicfunction__clone()
{
// Deep-copy the inner DateTime object so the clone is truly independent
$this->createdAt = clone $this->createdAt;
}
publicfunctionadd(MoneyValue $other): static
{
if ($this->currencyCode !== $other->currencyCode) {
thrownewInvalidArgumentException('Cannot add different currencies.');
}
returnnewstatic($this->amountInCents + $other->amountInCents, $this->currencyCode);
}
publicfunctiongetCreatedAt(): 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 stringecho"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 = [newMoneyValue(1000), newMoneyValue(2500), newMoneyValue(500)];
$discounted = array_map(fn($p) => $p(0.9), $prices); // 10% off eachecho"Discounted prices: " . implode(', ', $discounted) . PHP_EOL;
// --- Demo: __clone ---
$original = newMoneyValue(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;
Output
Subtotal: $49.99
Shipping: $5.99
Total: $55.98
Tax (20%): $11.20
Discounted prices: $9.00, $22.50, $4.50
Original timestamp: 2024
Clone timestamp: 2025
Pro Tip: __invoke and is_callable()
An object with __invoke defined returns true from is_callable() and satisfies callable type hints. This means you can inject a stateful MoneyValue, RateLimiter, or Validator object anywhere PHP expects a plain callable — no closure wrapper needed. It's one of the cleanest patterns for dependency-injected middleware.
Production Insight
A caching service used __invoke as a memoised function. The object held a cache array in its state. Because __invoke returns $this for chaining, but the developer forgot and returned the cached value directly — breaking the state pattern.
Always check the return type of __invoke matches the expected signature.
Rule: __invoke returns a value, not $this (unless you explicitly want callable chaining).
Other Magic Methods: __sleep, __wakeup, __serialize, __unserialize, __debugInfo, and __set_state
PHP has several less-used magic methods that solve specific problems. You probably won't need them every day, but when you do, they save hours of manual work.
__sleep and __wakeup are the old serialization hooks. __sleep returns an array of property names to serialize (useful for excluding resources like file handles). __wakeup is called after unserialization, perfect for re-establishing connections or re-initializing cached state. However, these are superseded in PHP 7.4+ by __serialize and __unserialize, which return associative arrays instead of property name lists. This gives you full control over the serialized representation without exposing property names. If you're writing new code, use __serialize/__unserialize; __sleep/__wakeup are legacy.
__debugInfo controls what var_dump() shows for your object. Without it, var_dump dumps all properties — including internal arrays that may contain sensitive data or large datasets. With __debugInfo, you can choose to show only a summary, like 'User id=42, email=***' hiding the actual email. Essential for production debugging where you want to avoid leaking PII in logs.
__set_state is a static method that's called when you export the object with var_export(). It receives an array of properties and returns a new object instance. This is useful for building configuration objects that can be cached into PHP files via var_export().
SafeUser.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php
classSafeUser
{
private int $id;
private string $email;
private string $passwordHash;
private ?PDO $dbConnection = null;
publicfunction__construct(int $id, string $email, string $passwordHash)
{
$this->id = $id;
$this->email = $email;
$this->passwordHash = $passwordHash;
}
// __serialize controls what gets stored when you serialize() the objectpublicfunction__serialize(): array
{
return [
'id' => $this->id,
'email' => $this->email,
// Intentionally omit passwordHash and dbConnection
];
}
// __unserialize reconstructs the object from the serialized datapublicfunction__unserialize(array $data): void
{
$this->id = $data['id'];
$this->email = $data['email'];
$this->passwordHash = ''; // Must be loaded from a secure source after unserialize
$this->dbConnection = null; // Reconnect on demand
}
// __debugInfo controls what var_dump() shows — hide sensitive fieldspublicfunction__debugInfo(): array
{
return [
'id' => $this->id,
'email' => substr($this->email, 0, 3) . '***@***',
'hasPasswordHash' => $this->passwordHash !== '',
];
}
// __set_state is called by var_export()publicstaticfunction__set_state(array $properties): static
{
returnnewstatic(
$properties['id'],
$properties['email'],
$properties['passwordHash']
);
}
}
// --- Usage ---
$user = newSafeUser(42, 'alice@example.com', 'hashed_password_here');
echo"Serialized: " . serialize($user) . PHP_EOL;
echo"Debug info:\n";
var_dump($user);
echo"Exported: " . var_export($user, true) . PHP_EOL;
Legacy Warning: __sleep/__wakeup Are Deprecated in Spirit
PHP 7.4 introduced __serialize/__unserialize which offer better control and don't rely on property name arrays. New code should use these. __sleep/__wakeup still work, but the engine calls both if defined — mixing them causes unexpected behavior. Stick with one pair.
Production Insight
A session handler stored user objects with __sleep that accidentally excluded the 'role' property. After unserialization, all users were treated as guests with no role.
The fix was switching to __serialize which returns an explicit array, making the exclusion obvious.
Rule: always unit-test serialization round trips with assertions on every property.
Key Takeaway
Use __serialize/__unserialize instead of legacy __sleep/__wakeup.
__debugInfo prevents leakage of sensitive data in logs.
__set_state enables caching objects via var_export().
● Production incidentPOST-MORTEMseverity: high
The isset() Trap That Killed Customer Imports
Symptom
A batch import script used $config->region ?? 'default' to set defaults. When a config file had a valid 'region' key, the null coalescing operator always fell back to 'default', ignoring the actual value. Only partially — some records worked, others didn't.
Assumption
The team assumed that because __get worked, isset() would work too. They didn't know isset() calls __isset, not __get.
Root cause
The ApiResponseWrapper class (same pattern as the article) only implemented __get and __set. The __isset method was missing, so isset($response->region) always returned false even when the key existed in the payload array. The null coalescing operator (??) uses isset() internally, so it always returned the default value.
Fix
Added __isset to the class: checks isset($this->payload[$key]). After the fix, isset() and ?? worked correctly, and the import processed all records properly.
Key lesson
Any time you implement __get, always implement __isset too.
Treat __get, __set, __isset, __unset as an inseparable quartet.
Put a comment in your code: 'If you add __get, add __isset or null coalescing breaks.'
Production debug guideSymptom → Action guide for the most common magic method pitfalls5 entries
Symptom · 01
Null coalescing operator (??) returns default even though property exists
→
Fix
Check if __isset is implemented. isset() and ?? call __isset, not __get. Add __isset that checks the backing array.
Symptom · 02
unset($obj->prop) doesn't work — property still accessible after unset
→
Fix
Implement __unset to remove the key from the backing store. Without it, unset() does nothing for dynamic properties.
Symptom · 03
Method chaining breaks with 'Call to a member function on null'
→
Fix
Verify __call returns $this. If __call doesn't return the object, the chain stops at the first magic method call.
Symptom · 04
__call doesn't fire for private/protected methods on child classes
→
Fix
__call only fires for methods that don't exist or are inaccessible from the calling scope. Private methods in the parent are visible to the child — they don't trigger __call. Use protected visibility for methods you want to intercept.
Symptom · 05
Exception in __destruct causes script to halt with 'Fatal error: Exception thrown without a stack frame'
→
Fix
Never let __destruct throw. Wrap all __destruct logic in try/catch and log errors internally. Fail silently.
★ Quick Debug Cheat Sheet: Magic MethodsUse these commands to diagnose magic method issues in production or during development.
Value from __get is wrong or missing−
Immediate action
Check if the property name casing matches the backing array key.
Commands
var_dump($obj->__debugInfo()); // See internal state if __debugInfo is implemented
Return type must match the expected callable signature
__clone()
clone $object called
Deep-copying objects with nested object properties
Don't forget mutable inner objects — DateTime, arrays of objects, etc.
__sleep()
serialize() is called
Choose which properties to include in serialized representation
Superseded by __serialize in PHP 7.4+
__wakeup()
unserialize() is called
Re-establish database connections or reconnect resources
Superseded by __unserialize
__serialize()
serialize() is called (PHP 7.4+)
Return associative array representing object state
If both __sleep and __serialize are defined, only __serialize is called
__unserialize()
unserialize() is called (PHP 7.4+)
Restore object from serialized array
Must handle missing keys gracefully — use null coalescing with defaults
__debugInfo()
var_dump() is called
Hide sensitive data, show summaries of large internal arrays
Return array must not contain non-scalar values? Actually can, but var_dump will dump them recursively
__set_state()
var_export() is called
Reconstruct object from exported array
Must be static and return an instance of the class
Key takeaways
1
__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.
2
__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.
3
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.
4
__call should always throw a BadMethodCallException for unrecognised method names
silently swallowing unknown calls produces bugs that are nearly impossible to trace.
5
Use __serialize/__unserialize instead of legacy __sleep/__wakeup for new code to avoid property name coupling and gain full control over serialized format.
6
__debugInfo is your first line of defense against leaking sensitive data in production logs via var_dump or debug backtraces.
Common mistakes to avoid
5 patterns
×
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.
×
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.
×
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.
×
Not returning $this from __call in fluent interfaces
Symptom
Method chaining breaks with 'Call to a member function on null' because the next method call is attempted on the return value of the previous __call, which is null by default.
Fix
Always return $this from __call when building fluent interfaces. If the method is not part of the chain, return the expected value explicitly.
×
Using __sleep and __serialize together
Symptom
PHP calls both methods, but the engine only considers __serialize's return value. __sleep may produce side effects (e.g., closing resources) that break the serialization.
Fix
Stick with one pair. Use __serialize/__unserialize for new code; remove __sleep/__wakeup to avoid confusion.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between __get and a regular public property? Why ...
Q02SENIOR
If you implement __call in a class, will it fire when you call a method ...
Q03JUNIOR
A teammate clones an object and then complains that modifying the clone ...
Q04SENIOR
What is the difference between __sleep/__wakeup and __serialize/__unseri...
Q05SENIOR
Explain how __debugInfo can be used to prevent data leakage in productio...
Q01 of 05SENIOR
What is the difference between __get and a regular public property? Why would you choose __get over simply declaring the property public?
ANSWER
__get intercepts read access to inaccessible or non-existent properties, letting you implement dynamic behavior like lazy loading, validation, or mapping to different internal names. A public property exposes the property directly with no interception. You'd choose __get when the property doesn't physically exist (e.g., database column names accessed via an ORM) or when you need to add logic on read, such as logging access, computing derived values, or returning a default if the internal key is missing. The cost is a small performance overhead (~3x slower) and the need to also implement __isset, __set, __unset for full support.
Q02 of 05SENIOR
If 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.
ANSWER
No, __call does NOT fire when calling a method that exists but is marked private from outside the class. PHP checks method visibility first: if the method exists and is private, PHP throws a Fatal Error 'Call to private method' before __call is ever considered. __call only fires when the called method does not exist at all in the class hierarchy, or when it exists but is inaccessible from the calling scope (e.g., protected method called from outside the class/child context). For private methods, the method exists and is visible to the class itself, so PHP reports the visibility violation directly rather than delegating to __call.
Q03 of 05JUNIOR
A 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?
ANSWER
The most likely cause is a shallow copy — PHP's default clone copies references to inner objects, so both the original and clone point to the same inner object instances. Modifying the clone's inner object (e.g., a DateTime property) also affects the original. The fix is to implement __clone() and deep-copy any mutable inner objects: inside __clone(), reassign $this->innerObject = clone $this->innerObject; for each object-type property. This breaks the reference sharing so changes to the clone's inner objects don't affect the original. Immutable objects (like strings, integers) don't need deep copy because they're value types.
Q04 of 05SENIOR
What is the difference between __sleep/__wakeup and __serialize/__unserialize? When should you use each?
ANSWER
__sleep returns an array of property names to serialize; __wakeup reconstructs after unserialization. They're limited because they expose property names (which may change during refactoring) and can't skip properties based on dynamic conditions. __serialize/__unserialize (PHP 7.4+) return an associative array, giving full control over the serialized format without depending on property names. You should use __serialize/__unserialize for any new code. Only use __sleep/__wakeup when maintaining legacy code that relies on them, but be aware that if both pairs are defined, only __serialize/__unserialize are called.
Q05 of 05SENIOR
Explain how __debugInfo can be used to prevent data leakage in production logs.
ANSWER
__debugInfo controls what var_dump(), error_log() when dumping objects, and debug backtraces show. Without it, var_dump exposes all properties — including passwords, tokens, internal IDs, or large arrays that clutter logs. By implementing __debugInfo, you return a custom array with only non-sensitive fields, or mask sensitive values (e.g., show only first three characters of an email). This is critical in production where logs are aggregated into centralized systems like ELK or Datadog — you don't want plain-text passwords ending up in searchable logs. __debugInfo doesn't affect normal code execution, only debug/dump contexts, making it a safe and effective way to reduce PII exposure.
01
What is the difference between __get and a regular public property? Why would you choose __get over simply declaring the property public?
SENIOR
02
If 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.
SENIOR
03
A 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?
JUNIOR
04
What is the difference between __sleep/__wakeup and __serialize/__unserialize? When should you use each?
SENIOR
05
Explain how __debugInfo can be used to prevent data leakage in production logs.
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
Should I use __get/__set or dedicated getter/setter methods?
Use magic methods when you need dynamic property access where the properties are not known at compile time (e.g., database columns, API response fields). Use explicit getter/setter methods when you have a fixed set of properties and want IDE autocompletion, static analysis, and type safety. Magic methods are a trade-off: they provide flexibility but sacrifice tooling support and introduce runtime behaviour that's harder to refactor. A common middle ground is to use magic methods for dynamic access and provide explicit methods for fixed properties.
Was this helpful?
05
What happens if I define both __sleep and __serialize in the same class?
If both __sleep and __serialize are defined, PHP's serialize() only calls __serialize and ignores __sleep. This can cause confusion if __sleep had side effects (e.g., cleaning up resources). The recommended approach is to define only one pair: either __sleep/__wakeup for legacy code, or __serialize/__unserialize for new code. Mixing them can lead to unexpected behavior, especially if you expect __sleep to run but it never does.