== compares values, === compares identity: what that means for your code
With scalars the difference between == and === is already tricky enough, but with objects an entirely separate dimension appears that many developers underestimate. == compares class and every property recursively for objects, while === checks only whether both operands refer to the exact same instance in memory. PHP offers no magic method like __equals to customize this behavior. This article explains both operators precisely and shows how to build predictable, custom comparison logic for value objects.
Table of Contents
- 1. Two fundamentally different comparisons for objects
- 2. How == actually works with objects
- 3. How === checks real identity
- 4. Recursion in nested objects and its pitfalls
- 5. Why PHP has no __equals
- 6. Custom comparison logic without operator overloading
- 7. Practical example: comparing a value object
- 8. Proving identity with spl_object_id
- 9. Rules of thumb for everyday practice
- 10. Summary
- 11. FAQ
1. Two fundamentally different comparisons for objects
For objects, == and === differ far more fundamentally in PHP than for any other type. While both operators merely differ in how strictly they handle type conversion for scalars, with objects they answer entirely different questions: == asks whether two objects are equal in content, === asks whether they are exactly the same instance. Anyone unaware of this distinction ends up writing comparisons that behave completely differently from what was intended.
This distinction is not a PHP peculiarity without precedent, it mirrors a well known concept from other languages: value equality versus reference identity. The decisive difference from languages like Java or C Sharp, however, is that PHP offers no overridable method for value equality, the behavior of == with objects is hardwired into the language and cannot be customized per class.
2. How == actually works with objects
The == operator returns true for two objects exactly when both are instances of the same class and all of their properties, compared with ==, are also equal. The comparison happens recursively: if a property itself holds an object, that object is compared again by the same rules. Two objects of different classes are never equal with ==, even if they carry identical properties with identical values.
This rule has an important consequence: two separately created instances of a class with the same constructor values are equal with ==, even though they are two completely independent objects in memory. For simple, immutable data containers this is often exactly the desired behavior, but for more complex objects with resource handles, timestamps, or circular references, the recursive comparison can become unexpectedly expensive or even faulty in edge cases.
<?php
declare(strict_types=1);
final class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
}
$a = new Point(1.0, 2.0);
$b = new Point(1.0, 2.0);
var_dump($a == $b); // true, same class and same property values
var_dump($a === $b); // false, two distinct instances
3. How === checks real identity
The === operator checks, for objects, whether both operands point to the exact same internal object handle. Internally PHP tracks every object through a unique handle reference, independent of any variable that may currently reference it. Assigning an object to a new variable through a simple assignment does not create a copy, it merely creates another reference to the same handle, which is why === returns true in that case.
This exact behavior makes === the right tool when identity, not equivalence, is actually the question, for example when checking whether an event listener has already been registered, or when removing a specific object from a collection. In both cases what matters is whether it is exactly the same object, not whether an equivalent object exists.
4. Recursion in nested objects and its pitfalls
The recursive nature of == quickly becomes a trap with deeply nested object structures. If an object contains a property holding a collection of further objects, for example an order with multiple line items, == recursively compares every single item against the corresponding item of the other object. For large object graphs this can quickly become expensive and in many cases is not the desired behavior functionally either.
It gets even more problematic with circular references, where two objects reference each other. Comparing such objects with == can end up in an infinite loop, or at least produce a confusing failure that is hard to trace. In practice, == should therefore only be used deliberately on manageable, shallow structures for objects, not as a generic default for arbitrary object comparisons.
5. Why PHP has no __equals
Unlike Java, where every class implicitly inherits from Object and can override equals, PHP offers no comparable magic method. There is no way to specifically define the behavior of == for a custom class. The only exception involves SPL classes that implement an ArrayAccess or Comparable like interface, but even those do not change the fundamental behavior of == or === at all.
This is a deliberate language decision: it prevents hidden, surprising side effects on the comparison operator, as occasionally happens in other languages when a poorly implemented equals method is inconsistent with hashCode. The price is that every class with functionally relevant equality logic must offer it explicitly as its own method, instead of relying on the operators.
6. Custom comparison logic without operator overloading
The established convention in the PHP community is an explicit equals method that precisely defines which properties matter for equality and how they are compared. This makes the comparison logic self documenting and readable, instead of relying on the implicit, recursive behavior of ==, which quickly becomes unpredictable for more complex classes.
An additional benefit of a custom equals method is the ability to deliberately include only functionally relevant properties. An object may carry internal, technical properties, such as a cache value or a creation timestamp, that are irrelevant to functional equality but would still skew an == comparison, since == unconditionally includes every property.
7. Practical example: comparing a value object
A monetary amount illustrates the difference well. A Money value object should consider two amounts equal exactly when amount and currency match, regardless of whether it is the same instance. An explicit equals method makes this rule clear and testable, while == delivers the same result in the simple case but can easily produce wrong results once additional, functionally irrelevant properties are added.
This method can then be used in collections, sorting, or duplicate checks, without anyone needing to rely on the implicit behavior of ==. Especially in larger codebases with many value objects, this consistency pays off, because comparison behavior is defined in a single, clearly visible place instead of being implicitly derived from the class's property structure.
<?php
declare(strict_types=1);
final class Money
{
public function __construct(
private readonly int $amountInCents,
private readonly string $currency,
private readonly DateTimeImmutable $createdAt,
) {
}
/**
* Compares only the functionally relevant values, ignores createdAt.
*/
public function equals(self $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
}
$a = new Money(1999, 'EUR', new DateTimeImmutable('2026-01-01'));
$b = new Money(1999, 'EUR', new DateTimeImmutable('2026-08-08'));
var_dump($a->equals($b)); // true, equal in business terms
var_dump($a == $b); // false, createdAt differs
8. Proving identity with spl_object_id
Sometimes object identity needs to be not just checked but used as a unique key, for example in a map whose keys are object instances. Since PHP arrays do not accept objects as keys, spl_object_id helps by producing a unique integer that stays stable for the lifetime of the object. Before PHP 7.2, spl_object_hash was the common alternative, spl_object_id is more performant today and should be preferred.
One important caveat applies: the ID returned by spl_object_id can be reused for a new object once the original is destroyed by the garbage collector. As long as a reference to the original object is actively kept, the ID stays unique, but without that guarantee you should not rely on the ID alone, and instead use the object itself as a key in an SplObjectStorage, which was designed exactly for this purpose.
<?php
declare(strict_types=1);
$storage = new SplObjectStorage();
$order = new stdClass();
$storage[$order] = 'processed';
var_dump($storage->contains($order)); // true
var_dump(spl_object_id($order)); // e.g. int(1), stable while $order lives
9. Rules of thumb for everyday practice
A good baseline: use === for identity checks, such as removing a specific object from a list or checking whether a callback is already registered. Use a custom equals method for functional equality, especially with value objects where two independent instances holding the same values should count as equal. Using == on objects should be deliberately avoided, except for simple, shallow data containers with no irrelevant properties.
This clear separation avoids an entire class of subtle bugs that typically only surface once additional properties are added or nesting gets deeper. Consistently distinguishing identity, functional equality, and the implicit recursive == semantics from the start avoids surprises that would otherwise only show up through failing tests in later project phases.
| Operator/Technique | What is compared | Recursive? | Typical use |
|---|---|---|---|
| == | Class plus every property | Yes | Simple, shallow data containers |
| === | Identical object handle | No | Identity checks, duplicate removal |
| Custom equals() method | Freely defined properties | As needed | Value objects with functional equality |
| spl_object_id() | Unique instance ID | No | Objects as a map key substitute |
| SplObjectStorage | Object identity as a real key | No | Object to value mappings |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Object Comparison == vs. ===
== on objects
Compares class and all properties recursively, no identity check.
=== on objects
Checks only whether both refer to the exact same instance in memory.
No __equals
PHP offers no magic method to customize comparison behavior.
Custom logic
equals() methods make functional equality explicit and testable.