Solving a structural array limitation
PHP arrays only accept integers and strings as keys, objects are silently rejected with an error or coerced into a useless string. SplObjectStorage closes that gap by allowing objects as keys, compared by identity. Using object metadata and graph traversal as examples, we show where this actually helps in day-to-day code.
Table of Contents
- 1. Why an array won't accept objects as keys
- 2. Object metadata without polluting properties
- 3. Graph traversal with a visited-node marker
- 4. The API in detail: attach, detach, contains, offsetSet
- 5. Performance characteristics: attach, contains, and iteration
- 6. Comparison to WeakMap: reference strength as the core difference
- 7. Set operations: addAll, removeAll, and set semantics
- 8. Serialization and its limits for persistence
- 9. When SplObjectStorage genuinely pays off in day-to-day work
- 10. Summary
- 11. FAQ
1. Why an array won't accept objects as keys
Trying to use an object directly as an array key in PHP ends in a TypeError, because array_key only accepts integers and strings. A naive workaround is calling spl_object_hash() or the newer spl_object_id() and using the returned string or integer as the key. That technically works, but loses the actual object reference and requires a second, parallel structure to map keys back to objects.
This is exactly the problem SplObjectStorage solves: it's a map whose keys are objects, compared by object identity rather than value equality. Two different instances with identical properties count as different keys, which is exactly the desired behavior in many cases, for example when two orders happen to share the same values but still need to be treated independently.
$order1 = new Order('A-100');
$order2 = new Order('A-100'); // identical values, different instance
$storage = new SplObjectStorage();
$storage[$order1] = ['status' => 'processing'];
$storage[$order2] = ['status' => 'pending'];
var_dump($storage->count()); // int(2), identity beats value equality
2. Object metadata without polluting properties
A common use case is attaching metadata to objects that shouldn't have a matching property of their own, either because the class comes from a third-party library or because the metadata is only relevant temporarily for a specific processing step. Instead of extending a foreign class with an extra field or maintaining a separate array keyed by object hashes, SplObjectStorage works as an external, cleanly separated metadata layer.
This is particularly valuable for value objects or entities whose classes should be kept deliberately lean. A validation pass can collect error messages per object in a SplObjectStorage instance without the validated objects ever knowing that error list exists. Once the pass finishes, the storage is simply discarded, leaving no trace in the original objects.
final class ValidationContext
{
private SplObjectStorage $errors;
public function __construct()
{
$this->errors = new SplObjectStorage();
}
public function addError(object $entity, string $message): void
{
if (!$this->errors->contains($entity)) {
$this->errors[$entity] = [];
}
$this->errors[$entity][] = $message;
}
public function hasErrors(object $entity): bool
{
return $this->errors->contains($entity) && count($this->errors[$entity]) > 0;
}
}
3. Graph traversal with a visited-node marker
A second classic use is traversing object graphs, for example resolving nested category trees with cross-references or analyzing dependency graphs between services. Without a visited-node marker, a recursive traversal runs into an infinite loop on cyclic references. SplObjectStorage works great as a set of already-visited nodes, because contains() checks object identity in constant time without requiring the node class to carry its own visited flag.
The key advantage over a plain array keyed by spl_object_id() is that SplObjectStorage can also store arbitrary data per node, for example the traversal depth or the shortest path found so far. That lets you keep visited tracking and auxiliary data in a single structure instead of having to keep two parallel arrays in sync.
function traverse(ServiceNode $start): SplObjectStorage
{
$visited = new SplObjectStorage();
$queue = [$start];
while ($queue !== []) {
$node = array_shift($queue);
if ($visited->contains($node)) {
continue; // cycle detected, node already visited
}
$visited[$node] = ['depth' => count($visited)];
foreach ($node->getDependencies() as $dependency) {
$queue[] = $dependency;
}
}
return $visited;
}
4. The API in detail: attach, detach, contains, offsetSet
SplObjectStorage implements Countable, ArrayAccess, and Iterator, so you can use either the classic method-based API with attach() and detach() or the more compact array bracket syntax. attach($object, $data) adds an object with optional extra data, $storage[$object] = $data does functionally the same thing. Both forms are equivalent, in practice the array syntax has largely won out for readability.
During foreach iteration, the storage yields the objects themselves by default, extra data is retrieved via getInfo() inside the loop rather than through the second value of a key-value pair the way an array works. This is a common stumbling block for developers coming from array iteration who initially expect foreach ($storage as $obj => $data), which is syntactically valid but semantically means something different than expected.
foreach ($visited as $node) {
$data = $visited->getInfo(); // extra data for the current object
echo $node->getName() . ' depth: ' . $data['depth'] . PHP_EOL;
}
// Equivalent using the explicit method API
$visited->attach($newNode, ['depth' => 3]);
if ($visited->contains($newNode)) {
$visited->detach($newNode);
}
5. Performance characteristics: attach, contains, and iteration
Internally, SplObjectStorage manages its entries via the internal object handle, comparable to the value spl_object_id() returns. As a result, attach(), contains(), and detach() run in average constant time, regardless of how many objects are already stored. That makes the class practical even for graph traversals with several thousand nodes, since lookups don't get noticeably slower as the visited set grows.
As for iteration order, SplObjectStorage guarantees insertion order, similar to how a PHP array preserves its insertion order. That matters when, for example, validation errors need to be output in the order they occurred. An important difference from an array remains that removing an entry with detach() triggers no reindexing, simply because there are no numeric indices the rest of the structure would need to rely on.
6. Comparison to WeakMap: reference strength as the core difference
The most important difference between SplObjectStorage and WeakMap concerns reference strength. SplObjectStorage holds a strong reference to every stored object, so the garbage collector cannot free the object as long as it sits in the storage. That's harmless for metadata with a clear, bounded lifecycle, for example within a single request or a completed validation pass, but it can cause memory leaks in long-lived caches, because objects are kept artificially alive.
WeakMap, on the other hand, holds only weak references, so an object still gets freed once no other strong reference exists, and it automatically disappears from the map. For short-lived, explicitly managed structures like the traversal visited set shown above, SplObjectStorage is the simpler and faster choice, since it doesn't carry the extra overhead of weak references. For long-lived, process-wide caches, WeakMap is generally the safer option.
7. Set operations: addAll, removeAll, and set semantics
A lesser-known feature of SplObjectStorage is the pair of methods addAll() and removeAll(), which take over or remove the contents of an entire other storage instance. That lets you express classic set operations: the union of two visited sets comes from calling addAll() on a copy of the first set, while removeAll() effectively forms a difference set. There's no built-in method for a true intersection, a simple filter via contains() handles that case instead.
These set operations matter especially in graph algorithms, for example when two independently computed visited sets from a bidirectional search need to be merged. Since SplObjectStorage automatically excludes duplicates via object identity internally, addAll() correctly behaves like a set union without developers having to manually check for already-present objects.
$union = new SplObjectStorage();
$union->addAll($visitedFromA);
$union->addAll($visitedFromB); // duplicates are ignored automatically
$onlyInA = new SplObjectStorage();
$onlyInA->addAll($visitedFromA);
$onlyInA->removeAll($visitedFromB); // difference set A without B
8. Serialization and its limits for persistence
SplObjectStorage can technically be serialized, since the class implements serialization-like behavior via magic methods. In practice this is best avoided, because the serialized form does not preserve object identity across process boundaries: after deserialization, new object instances with new identities are created, so comparisons against originally referenced objects fail. For use cases that genuinely need persistence, custom serialization logic based on stable identifiers such as IDs is usually the more robust approach.
Another practical note concerns JSON serialization: SplObjectStorage doesn't implement meaningful JsonSerializable behavior by default, a direct json_encode() call usually returns an empty object. If you need to export the contents as JSON, explicitly transform the storage into an associative array, typically keyed by a stable identifier of each object as a substitute key.
9. When SplObjectStorage genuinely pays off in day-to-day work
SplObjectStorage pays off whenever objects need to serve as unique, identity-based keys and the associated data shouldn't live either in the object's own class or in a separately maintained helper structure. Typical candidates are validation contexts, visited sets for graph traversal, object-scoped event listener registries, or temporary computation caches within a single request.
It's less useful when the lifetime of the stored objects is meant to significantly outlast the storage's own lifecycle, here WeakMap is almost always the better choice to avoid memory leaks. SplObjectStorage is similarly not worth reaching for when only string or integer keys are actually needed, since a plain array is then both simpler and free of the extra object overhead.
| Feature | SplObjectStorage | Array with spl_object_id | WeakMap |
|---|---|---|---|
| Objects as keys | Directly supported | Only via workaround (string/int) | Directly supported |
| Reference strength | Strong, blocks GC | Strong, indirectly | Weak, GC-friendly |
| Extra data per object | Yes, via getInfo/attach | Second parallel array needed | Yes, as value |
| Set operations | addAll, removeAll built in | Must be rebuilt manually | Not built in |
| Typical use | Short-lived metadata, graphs | Legacy, mostly replaceable | Long-lived caches |
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
SplObjectStorage: the essentials at a glance
Problem
Arrays only accept integers and strings as keys, never objects.
Solution
SplObjectStorage uses object identity as the key and stores extra data separately.
Practice
Ideal for validation metadata and visited sets in graph traversal.
Boundary
For long-lived caches without memory leaks, WeakMap is preferable thanks to weak references.