Magic Methods: Serialization, Strings & Cloning
Master __serialize, __unserialize, __toString, __invoke, __clone, and __debugInfo with real, working examples of each, and understand how they interact with PHP's serialization, string-conversion, callable, cloning, and debugging systems.
This second half of PHP's magic-method toolkit covers a different category of interception: turning an object into other representations (a serialized string, a printable string, a debug dump) and intercepting structural operations (calling an object like a function, cloning it). These are used constantly in real codebases — every time you var_dump() an ORM entity and see a clean summary instead of internal noise, every time you echo an object and get a formatted string, a magic method is behind it.
__serialize and __unserialize: controlling PHP's native serialization
PHP's built-in serialize()/unserialize() functions convert an object to and from a specific string format (used by session storage, some caching backends, and legacy interop). By default, PHP serializes all of an object's properties automatically, but __serialize(): array lets you override exactly what gets included — return an associative array representing the data to persist — and __unserialize(array $data): void lets you control exactly how that array is turned back into a live object's state.
This pairing (introduced in PHP 7.4, replacing the older, error-prone Serializable interface with serialize()/unserialize() magic methods of its own) is essential whenever a class holds something that cannot or should not survive serialization verbatim — a database connection handle, a file resource, a closure — or when you want to version your serialized format (e.g., add a '_version' => 2 key and branch in __unserialize to handle old-format data gracefully during a rolling deploy).
A common real pattern: a class holding a lazily-created, unserializable resource (like a PDO connection) excludes it from __serialize()'s returned array entirely, and __unserialize() reconstructs only the safe scalar/array state, leaving the resource to be lazily recreated the next time it's actually needed — this avoids both a crash on serialize and a stale/broken handle on unserialize.
__toString: object-to-string conversion
public function __toString(): string is called automatically whenever an object is used in a string context: string concatenation ('Total: ' . $money), passed to echo/print, used inside a double-quoted string interpolation "{$money}", or explicitly cast with (string) $money. Since PHP 8.0, classes implementing the built-in Stringable interface are recognized automatically wherever string is type-hinted; any class defining __toString() implicitly satisfies Stringable without declaring it explicitly, though declaring it explicitly is still good practice for clarity and static analysis.
The classic, well-behaved use is a value object that has one obvious canonical textual form — a Money class returning '$19.99', a Uuid class returning its canonical hyphenated string, a DateRange returning '2024-01-01 to 2024-01-31'. The method must always return a string (enforced by the return type since PHP 8.0's stricter magic method signatures) — returning anything else, or throwing, previously caused a fatal error, though PHP 8.0+ does allow exceptions to propagate from __toString() (an old restriction was lifted).
A common mistake is putting expensive logic (a database query, heavy computation) inside __toString() — because it's invoked implicitly and silently by string coercion, that cost is easy to trigger accidentally (for example, inside a debug echo statement someone left in) without realizing an expensive operation just ran.
__invoke: making objects callable
public function __invoke(...$args) lets an object be called directly as if it were a function: $obj(...). Such an object satisfies PHP's callable type wherever a callback is expected — array_map($myInvokableObject, $items) works exactly like passing a closure. This is the mechanism behind 'callable class' patterns: a single-purpose class whose entire API is 'call me with these inputs, get this output,' often used for middleware, event listeners, or pipeline stages where a class gives you more structure (constructor-injected dependencies, named class for stack traces) than an anonymous closure would.
A concrete example: a Discount class configured with a percentage in its constructor, whose __invoke(float $price): float applies that discount — you can then pass new Discount(10) anywhere a callable(float): float is expected, getting dependency-injected, testable, named behavior instead of an opaque closure capturing variables from its surrounding scope.
__clone: customizing the clone operation
PHP's clone $obj expression creates a shallow copy of an object by default: all scalar properties are copied by value, but any property holding an object reference still points at the *same* nested object as the original — both the clone and the original share it. public function __clone() is called automatically, with no arguments, immediately after this shallow copy is made, and its job is to fix up anything that needs a genuinely independent (deep) copy — typically by explicitly cloning nested object properties inside it: $this->address = clone $this->address;.
This distinction (shallow-by-default, deep-if-you-write-__clone) matters enormously in practice: forgetting to deep-clone a mutable nested object means changes to the clone's nested object silently also affect the 'original,' a classic and hard-to-spot bug. __clone() is your one hook to prevent that, and it is also where you'd reset any state that legitimately should NOT be shared with a clone — for example, giving a cloned entity a fresh, unset ID before it's saved as a new database row.
__debugInfo: customizing var_dump() output
By default, var_dump() on an object prints every property, including private/protected ones and their raw internal values — which can be noisy or, worse, leak sensitive data (a password hash, an API token) straight into logs or debug output. public function __debugInfo(): array lets you override exactly what var_dump() shows, returning an associative array of whatever summary you want displayed in place of the real property list.
This is precisely how many frameworks' value objects and entities avoid dumping sensitive fields: a User class's __debugInfo() might return every property except password, replacing it with a fixed 'password' => '[redacted]' marker, so a stray var_dump($user) during debugging never accidentally prints a real password hash into a log file or terminal.
Serialization, string, invoke, clone, debug signatures
__serialize/__unserialize are the modern (PHP 7.4+) replacement for the deprecated Serializable interface — prefer them in all new code. __clone() takes no parameters and returns nothing (void) — it operates by mutating $this after PHP has already performed the shallow copy.
class Example
{
public function __serialize(): array {}
public function __unserialize(array $data): void {}
public function __toString(): string {}
public function __invoke(mixed ...$args): mixed {}
public function __clone(): void {}
public function __debugInfo(): array {}
}Code Examples
Money's __toString() gives it one obvious, canonical textual form. Anywhere the object lands in a string context — interpolation, concatenation, echo — PHP calls __toString() automatically to get that representation, so callers never manually format it themselves. Expected result: Total: USD 19.99 Total: USD 19.99 clone performs a shallow copy by default, so without __clone(), $copy->address and $original->address would be the exact same Address object. __clone() explicitly deep-clones the nested Address, giving the copy a genuinely independent object graph. Expected result: Paris Berlin
Common mistake: Omitting __clone() entirely here would make both $original and $copy share one Address instance, so 'Berlin' would incorrectly print for both.
__serialize/__unserialize protect against trying to (un)serialize a live, unserializable PDO handle by excluding it and lazily reconnecting instead. __invoke lets the whole client be used as a simple callable for running a query. __debugInfo prevents credentials embedded in the DSN from leaking into a var_dump().
public function __serialize(): array { return ['dsn' => $this->dsn]; }
final class Money
{
public function __construct(
private readonly int $cents,
private readonly string $currency = 'USD',
) {}
public function __toString(): string
{
return sprintf('%s %.2f', $this->currency, $this->cents / 100);
}
}
$price = new Money(1999);
echo "Total: {$price}\n"; // string interpolation triggers __toString
echo 'Total: ' . $price . "\n"; // concatenation triggers __toString toofinal class Address
{
public function __construct(public string $city) {}
}
final class Person
{
public function __construct(
public string $name,
public Address $address,
) {}
public function __clone(): void
{
// Without this, $this->address would still point at the SAME
// Address object as the original — mutating the clone's address
// would silently mutate the original's address too.
$this->address = clone $this->address;
}
}
$original = new Person('Ana', new Address('Paris'));
$copy = clone $original;
$copy->address->city = 'Berlin';
echo $original->address->city . "\n"; // Paris (unaffected)
echo $copy->address->city . "\n"; // Berlinfinal class ApiClient
{
private ?PDO $connection = null; // not serializable
public function __construct(private readonly string $dsn) {}
private function connect(): PDO
{
return $this->connection ??= new PDO($this->dsn);
}
public function __serialize(): array
{
// Deliberately exclude the live PDO connection
return ['dsn' => $this->dsn];
}
public function __unserialize(array $data): void
{
$this->dsn = $data['dsn'];
$this->connection = null; // reconnect lazily on next use
}
public function __invoke(string $query): array
{
return $this->connect()->query($query)->fetchAll(PDO::FETCH_ASSOC);
}
public function __debugInfo(): array
{
return ['dsn' => preg_replace('/:.*@/', ':***@', $this->dsn)]; // hide credentials
}
}
$client = new ApiClient('mysql:host=db;dbname=app;user=root;pass=secret');
$serialized = serialize($client); // only 'dsn' is stored, no live connection
$restored = unserialize($serialized); // reconnects lazily, connection starts null
$rows = $client('SELECT 1'); // __invoke lets the object be called directly
var_dump($c
…Shallow clone vs. deep clone via __clone()
Default clone() is shallow — nested object properties remain shared references unless __clone() explicitly clones them too.
BEFORE clone:
$original -> Person { address: --------> Address{city:'Paris'} }
AFTER `clone $original` WITHOUT __clone():
$original -> Person { address: --\
+--> SAME Address{city:'Paris'}
$copy -> Person { address: --/ (shared! mutation affects both)
AFTER `clone $original` WITH __clone() deep-cloning address:
$original -> Person { address: --------> Address{city:'Paris'} }
$copy -> Person { address: --------> Address{city:'Paris'} } (separate object)Important Rules
__serialize()/__unserialize() are the modern replacement for the Serializable interface (deprecated) — use them for any class needing custom serialize()/unserialize() behavior.
__toString() must return a string; since PHP 8.0 it may also let exceptions propagate, but avoid putting expensive operations (DB queries) inside it since it fires implicitly and easily unnoticed.
__invoke() makes an object satisfy PHP's callable type — usable anywhere a function/closure would be, e.g. array_map(), usort(), event listener registries.
clone is shallow by default: nested object properties remain shared references between original and copy unless __clone() explicitly re-clones them.
__debugInfo() lets you scrub sensitive fields (passwords, tokens, credentials) from var_dump() output without changing the object's real internal properties.
Where this shows up
Value objects across the PHP ecosystem (Carbon dates, Ramsey UUIDs, money libraries like brick/money) implement __toString() for clean, readable output. Laravel's session serialization and cache drivers rely on __serialize/__unserialize for models that hold non-serializable resources. Event-dispatcher and middleware-pipeline libraries frequently accept __invoke-based 'callable classes' as listeners/handlers so implementations get constructor injection and a real, debuggable class name in stack traces instead of an anonymous closure. Doctrine entities customize __debugInfo (or use library-provided base classes that do) to keep lazy-loaded proxy internals and sensitive fields out of debug dumps.
class Report { public function __toString(): string { return $this->db->query('SELECT summary FROM reports')->fetchColumn(); // hidden DB call } } echo "Debug: {$report}"; // accidentally triggers a real database query class Report { public function summary(): string { return $this->db->query('SELECT summary FROM reports')->fetchColumn(); } } echo 'Debug: '. $report->summary(); // explicit, visible call
Because __toString() fires implicitly on any string coercion, hiding an expensive database query inside it means the query can run accidentally — for example, from a stray debug echo — without anyone intending to trigger it. Expensive work belongs behind an explicit, named method.
[multiple-choice] By default, what does PHP's clone $obj do to a property that holds a reference to another object? A. It deep-clones that nested object automatically B. It leaves the clone's property pointing at the exact same nested object instance as the original (shared reference) C. It sets that property to null on the clone D. It throws an error unless __clone() is defined
B. It leaves the clone's property pointing at the exact same nested object instance as the original (shared reference) — clone is shallow by default: scalar properties are copied by value, but object-valued properties remain references to the same shared instance unless __clone() explicitly re-clones them.
[multiple-choice] What is __debugInfo() used for? A. Converting an object to a string for echo B. Overriding what var_dump() displays for an object, useful for hiding sensitive fields C. Controlling how an object is serialized with serialize() D. Making an object callable like a function
B. Overriding what var_dump() displays for an object, useful for hiding sensitive fields — __debugInfo() lets a class control exactly what var_dump() prints for it, commonly used to redact sensitive properties like passwords or tokens from debug output.
[predict-output] A class defines __invoke(int $n): int returning $n * 2. Given $double = new Doubler(); echo $double(21); what is printed? A. A fatal error, objects cannot be called like functions B. 42 C. 21 D. Doubler
B. 42 — __invoke lets an object be called directly with (). $double(21) calls __invoke(21), returning 21 * 2 = 42, which echo then prints.
Write a class Counter with a private int $value and an __invoke(int $step = 1): int method that increments $value by $step and returns the new value. Then write a __clone() method that is a no-op comment explaining why NO explicit deep-clone is needed here (hint: think about what type $value is).
final class Counter { private int $value = 0; public function __invoke(int $step = 1): int { return $this->value += $step; } public function __clone(): void { // No action needed: $value is a scalar (int), and PHP's default // shallow clone already copies scalar properties by value, not by reference. } }
Scalar properties (int, string, bool, float) are always copied by value in PHP, even in a shallow clone — __clone() only needs extra work for properties holding objects or resources.