Ein Shop soll sich zu jedem Kundenobjekt den bisherigen Umsatz merken, ohne dass die Klasse Kunde dafür ein weiteres Feld bekommt. Der naheliegende Versuch mit einem Array scheitert in der ersten Zeile: PHP lehnt das Objekt als Schlüssel mit TypeError: Cannot access offset of type Kunde on array ab. Für genau diesen Fall gibt es PHP SplObjectStorage. Wie Beobachter ihre Empfänger darin verwalten, zeigt das Tutorial zum Observer Pattern, wie ein Server seine offenen Verbindungen darin hält, das Tutorial zu WebSockets mit PHP. Hier geht es um die Klasse selbst: wie sie Daten an Objekte hängt, wo sie still etwas anderes tut als erwartet und wann WeakMap die bessere Wahl ist.
Alle Beispiele zu PHP SplObjectStorage liefen unter PHP 8.1 bis 8.5, unter Windows und Linux. Wo sich die Versionen unterscheiden, steht es beim jeweiligen Fall.
Objekte als Schlüssel: was PHP SplObjectStorage löst
Ein Array akzeptiert als Schlüssel nur Zahlen und Zeichenketten. PHP SplObjectStorage nimmt dagegen Objekte als Schlüssel und legt zu jedem Objekt beliebige Daten ab, eine Zahl, einen Text, ein Array oder wieder ein Objekt. Jedes Objekt steht höchstens einmal darin. Lässt man die Daten weg, bleibt eine Menge von Objekten übrig, in der sich schnell prüfen lässt, ob ein bestimmtes Objekt schon dabei ist.
Weil PHP SplObjectStorage das Interface ArrayAccess umsetzt, sieht der Zugriff aus wie bei einem Array:
<?php
final class Kunde
{
public function __construct(
public string $name
) {}
}
$anna = new Kunde('Anna');
$ben = new Kunde('Ben');
$liste = [];
try {
$liste[$anna] = 120.50;
} catch (TypeError $e) {
echo $e->getMessage(), PHP_EOL;
}
/* Cannot access offset of type Kunde on array */
$umsatz = new SplObjectStorage();
$umsatz[$anna] = 120.50;
$umsatz[$ben] = 89.90;
echo count($umsatz), PHP_EOL;
/* 2 */
echo $umsatz[$anna], PHP_EOL;
/* 120.5 */
unset($umsatz[$ben]);
echo count($umsatz), PHP_EOL;
/* 1 */
Bis PHP 8.2 lautet die Meldung des Arrays noch Illegal offset type, ab 8.3 nennt PHP den Klassennamen. Bei PHP SplObjectStorage sind die eckigen Klammern seit PHP 8.5 auch der empfohlene Weg, weil attach(), detach() und contains() als veraltet gelten. Alle folgenden Beispiele nutzen sie.
Syntax im Original: Methoden und Rückgabewerte
Die Methoden, die im Alltag zählen, in der Schreibweise des Handbuchs:
class SplObjectStorage implements Countable,
SeekableIterator, Serializable, ArrayAccess
/* The SplObjectStorage class provides a map from
objects to data or, by ignoring data, an object
set. This dual purpose can be useful in many
cases involving the need to uniquely identify
objects. */
offsetSet(object $object, mixed $info = null): void
offsetGet(object $object): mixed
offsetExists(object $object): bool
offsetUnset(object $object): void
getInfo(): mixed
setInfo(mixed $info): void
addAll(SplObjectStorage $storage): int
removeAll(SplObjectStorage $storage): int
removeAllExcept(SplObjectStorage $storage): int
getHash(object $object): string
/* object - the object used as key, compared by
getHash(), by default the identity
info - the data stored for the object
storage - the storage to add, remove or keep
return - addAll(), removeAll() and
removeAllExcept() return the number
of objects left in the storage.
current() returns the current object of the
iteration, key() its index, next() moves to
the next item. Two instances with equal values
are different objects. It is not safe to modify
the storage in a foreach loop: no error is
raised, but items are skipped or repeated.
attach(), detach() and contains() are
deprecated as of PHP 8.5, use offsetSet(),
offsetUnset() and offsetExists(). */
Der erste Kommentar ist der Einleitungssatz aus der Dokumentation zu PHP SplObjectStorage: eine Zuordnung von Objekten zu Daten oder, ohne Daten, eine Menge. Hinter der Array-Schreibweise stecken offsetSet(), offsetGet(), offsetExists() und offsetUnset(), die man selten direkt aufruft. Die drei Mengenmethoden geben die neue Anzahl zurück. Seit PHP 8.4 setzt die Klasse zusätzlich SeekableIterator um und kennt seek(), in 8.1 bis 8.3 fehlt diese Methode.
Warum Daten zu einem Objekt plötzlich fehlen
Ein Bestellmodul berechnet zu jeder Bestellung die Versandkosten und legt sie in einer PHP SplObjectStorage ab. Ein zweiter Codepfad will sicherstellen, dass die Bestellung registriert ist, und setzt sie noch einmal, diesmal ohne Daten. Danach steht statt der Versandkosten NULL in der Zuordnung, und keine Meldung weist darauf hin:
<?php
$anna = new Kunde('Anna');
$s = new SplObjectStorage();
$s[$anna] = null;
var_dump(isset($s[$anna]));
/* bool(true), obwohl die Daten null sind */
var_dump($s[$anna] ?? 'leer');
/* string(4) "leer" */
try {
echo $s[new Kunde('Cem')];
} catch (UnexpectedValueException $e) {
echo $e->getMessage(), PHP_EOL;
}
/* Object not found */
$s[$anna] = 'Stammkunde';
$s->offsetSet($anna);
var_dump($s[$anna]);
/* NULL, die Daten sind weg */
Erstens meldet isset() auch dann true, wenn die Daten null sind, denn geprüft wird nur, ob das Objekt in der PHP SplObjectStorage steht. Der Operator ?? liefert dagegen bei null den Ersatzwert. Zweitens wirft ein fehlendes Objekt beim Lesen eine UnexpectedValueException mit der knappen Meldung Object not found; mit ?? kommt stattdessen still der Ersatzwert. Drittens ersetzt jedes erneute Setzen die Daten, auch durch den Standardwert null. Ein zweiter Eintrag entsteht dabei nie, count() bleibt bei 1.
Ob man vor dem Hinzufügen prüfen muss, ob das Objekt schon drin ist, hängt also vom Zweck ab. Für eine reine Menge ist die Prüfung überflüssig. Hängen Daten am Objekt, gehört sie davor: if (!isset($s[$o])) { $s[$o] = $start; }. Wer das an mehreren Stellen braucht, kapselt es in einer kleinen Methode, wie sie im Praxisteil vorkommt.
Die zweite stille Falle betrifft Arrays als Daten. Ein Warenkorb ordnet jedem Kunden eine Liste von Positionen zu und hängt eine neue Position mit $s[$kunde][] = $position an. Die Position kommt nie an:
<?php
$anna = new Kunde('Anna');
$s = new SplObjectStorage();
$s[$anna] = ['Tasse'];
$s[$anna][] = 'Teller';
/* Notice: Indirect modification of overloaded
element of SplObjectStorage has no effect */
echo implode(', ', $s[$anna]), PHP_EOL;
/* Tasse */
$liste = $s[$anna];
$liste[] = 'Teller';
$s[$anna] = $liste;
echo implode(', ', $s[$anna]), PHP_EOL;
/* Tasse, Teller */
PHP gibt nur eine Notice aus, die auf vielen Produktionsservern gar nicht angezeigt wird. Der Grund: $s[$kunde] liefert eine Kopie der Daten, und die Änderung an der Kopie verpufft. Abhilfe schafft das Muster aus dem zweiten Teil des Beispiels, also holen, ändern und zurückschreiben. Alternativ speichert man ein ArrayObject als Daten, das als Objekt geteilt wird und Einträge direkt annimmt; im Test enthielt es danach beide Werte. Wie ArrayObject funktioniert, erklärt das Tutorial zu PHP ArrayObject. Damit solche Notices schon beim Entwickeln auffallen, gehört error_reporting(E_ALL) in jede Testumgebung.
PHP 8.5: attach(), detach() und contains() sind veraltet
Viele ältere Beispiele zu PHP SplObjectStorage nutzen attach(), detach() und contains(). Nach dem Umstieg auf PHP 8.5 füllt genau dieser Code das Fehlerprotokoll. Gemessen mit PHP 8.5.8 lautet die Meldung für attach():
Deprecated: Method SplObjectStorage::attach() is deprecated since 8.5, use method SplObjectStorage::offsetSet() instead
Für contains() nennt PHP offsetExists() als Ersatz, für detach() offsetUnset(). Ärgerlich wird es in Testsuiten und Frameworks, die Deprecations in Exceptions umwandeln. Mit einem Error-Handler, der eine ErrorException wirft, brach der Aufruf im Test sofort ab, und der Code danach lief nicht mehr. Unter PHP 8.4 meldet dieselbe Zeile nichts. Betroffen sind nur diese drei Methoden von PHP SplObjectStorage. Per Reflection gelten addAll(), removeAll(), removeAllExcept(), getInfo(), setInfo(), getHash() und seek() weiter als nicht veraltet.
| veraltet ab 8.5 | Ersatz als Methode | Array-Schreibweise |
$s->attach($o, $d) | $s->offsetSet($o, $d) | $s[$o] = $d; |
$s->contains($o) | $s->offsetExists($o) | isset($s[$o]) |
$s->detach($o) | $s->offsetUnset($o) | unset($s[$o]); |
Die Umstellung ist mechanisch:
<?php
$anna = new Kunde('Anna');
$s = new SplObjectStorage();
/* alt, ab PHP 8.5 je eine Deprecated-Meldung */
$s->attach($anna, 'Stammkunde');
$da = $s->contains($anna);
$s->detach($anna);
/* neu, PHP 8.1 bis 8.5 ohne Meldung */
$s[$anna] = 'Stammkunde';
$da = isset($s[$anna]);
unset($s[$anna]);
Die Array-Schreibweise von PHP SplObjectStorage lief im Test von PHP 8.1 bis 8.5 ohne jede Meldung. Ebenso still blieben die ausgeschriebenen Methoden offsetSet(), offsetExists() und offsetUnset(). Auch die Frage, worin sich contains() und offsetExists() unterscheiden, erledigt sich damit: im Verhalten gar nicht, beide prüfen über getHash() und lieferten im Test dasselbe Ergebnis, nur ist contains() veraltet. Wer mehrere PHP-Versionen betreut, sollte die nächste Version samt E_ALL schon in der Testumgebung mitlaufen lassen. Dann fallen solche Meldungen vor dem Update auf und nicht danach im Protokoll.
Durchlaufen mit foreach, getInfo() und next()
Eine Schleife über eine PHP SplObjectStorage liefert als Schlüssel nicht die Daten, sondern eine laufende Nummer, und als Wert das Objekt. An die Daten des aktuellen Objekts kommt man über getInfo() oder über $s[$objekt]:
<?php
$umsatz = new SplObjectStorage();
$umsatz[new Kunde('Anna')] = 120.50;
$umsatz[new Kunde('Ben')] = 89.90;
$umsatz[new Kunde('Cem')] = 15.00;
foreach ($umsatz as $i => $kunde) {
echo $i, ' ', $kunde->name, ': ';
echo $umsatz->getInfo(), PHP_EOL;
}
/* 0 Anna: 120.5
1 Ben: 89.9
2 Cem: 15 */
foreach ($umsatz as $kunde) {
$umsatz->setInfo($umsatz->getInfo() * 2);
}
var_dump($umsatz->getInfo());
/* NULL, der Durchlauf ist zu Ende */
setInfo() ändert die Daten des Objekts, auf dem der Durchlauf gerade steht; im Test waren danach alle Umsätze verdoppelt. Nach dem Ende der Schleife steht der interne Zeiger hinter dem letzten Objekt, und getInfo() liefert dort NULL. Ab PHP 8.4 springt seek(1) direkt auf das zweite Objekt, im Beispiel Ben. Eine Position außerhalb endet mit OutOfBoundsException: Seek position 5 is out of range.
Verschachtelte Schleifen über dieselbe Zuordnung gehen dagegen schief. Wer aus einem Team alle Paare bilden will, etwa um Termine gegeneinander zu prüfen, läuft zweimal über dieselbe PHP SplObjectStorage und bekommt zu wenig Paare:
<?php
$team = new SplObjectStorage();
foreach (['Anna', 'Ben', 'Cem'] as $name) {
$team[new Kunde($name)] = null;
}
$paare = 0;
foreach ($team as $a) {
foreach ($team as $b) {
$paare += $a !== $b ? 1 : 0;
}
}
echo $paare, PHP_EOL;
/* 2 statt 6 */
$paare = 0;
foreach ($team as $a) {
foreach (clone $team as $b) {
$paare += $a !== $b ? 1 : 0;
}
}
echo $paare, PHP_EOL;
/* 6 */
Statt sechs Paaren entstehen gemessen zwei, in PHP 8.1 bis 8.5 unter Windows und Linux gleich. PHP SplObjectStorage ist selbst ein Iterator und hat nur einen internen Zeiger, den sich beide Schleifen teilen. Nach dem ersten Durchlauf der inneren Schleife steht er am Ende, und die äußere hört nach Anna auf. Dasselbe passiert, wenn im Rumpf eine Funktion die Zuordnung durchläuft, etwa um sie für ein Protokoll aufzulisten. Die innere Schleife braucht deshalb eine eigene Kopie, mit clone wie im zweiten Teil. Ein Array aus iterator_to_array($team, false) taugt nur, wenn es vor der äußeren Schleife entsteht. Innerhalb der Schleife läuft auch diese Funktion über den gemeinsamen Zeiger, gemessen wieder zwei Paare.
Die Reihenfolge entspricht der Reihenfolge des Einfügens. Ein Objekt nach vorn holen lässt sich nicht direkt. Entfernt man es und setzt es neu, rückt es ans Ende: Aus Anna, Ben, Cem wurde so Ben, Cem, Anna. Das darf allerdings nicht innerhalb der Schleife passieren, wie der nächste Abschnitt zeigt.
Warum beim Löschen im foreach Objekte übrig bleiben
Ein Chat-Bot hält seine offenen Sitzungen in einer PHP SplObjectStorage und räumt einmal pro Minute die abgelaufenen auf: Schleife über alle Sitzungen, unset() für jede abgelaufene. Mit zwei, drei Sitzungen auf dem Entwicklungsrechner fällt nichts auf. Im Betrieb wächst die Liste trotzdem, weil manche abgelaufene Sitzung die Aufräumrunde überlebt. Im folgenden Beispiel sind von fünf Sitzungen alle außer c abgelaufen:
<?php
final class Sitzung
{
public function __construct(
public string $id,
public bool $abgelaufen
) {}
}
function sitzungen(): SplObjectStorage
{
$s = new SplObjectStorage();
foreach (['a', 'b', 'c', 'd', 'e'] as $id) {
$s[new Sitzung($id, $id !== 'c')] = null;
}
return $s;
}
function ids(SplObjectStorage $s): string
{
$ids = [];
foreach ($s as $x) {
$ids[] = $x->id;
}
return implode(' ', $ids);
}
$liste = sitzungen();
foreach ($liste as $s) {
if ($s->abgelaufen) {
unset($liste[$s]);
}
}
echo ids($liste), PHP_EOL;
/* b c e (PHP 8.1: b c) */
$liste = sitzungen();
$weg = new SplObjectStorage();
foreach ($liste as $s) {
if ($s->abgelaufen) {
$weg[$s] = null;
}
}
$liste->removeAll($weg);
echo ids($liste), PHP_EOL;
/* c */
$liste = sitzungen();
foreach (clone $liste as $s) {
if ($s->abgelaufen) {
unset($liste[$s]);
}
}
echo ids($liste), PHP_EOL;
/* c */
Seit PHP 8.2 bleiben b und e stehen, also genau die Sitzungen, die direkt auf eine gelöschte folgen. PHP 8.1 verhält sich anders, dort bleibt von den abgelaufenen Sitzungen nur b übrig. Wer statt unset() die Methode offsetUnset() oder das veraltete detach() aufruft, hat ein anderes Problem: Diese Methoden setzen den Durchlauf auf den Anfang zurück, frühere Objekte kommen also noch einmal an die Reihe. Mit fünf Objekten Anna bis Emil ergab das im Test folgendes Bild:
| im foreach | PHP 8.1 | PHP 8.2 bis 8.5 |
unset() für alle | Ben bleibt übrig | Ben und Dora bleiben übrig |
unset() nur für Dora | Ben und Cem doppelt verarbeitet | Emil wird nie besucht |
offsetUnset() für Cem und Emil | Ben dreimal, Dora zweimal verarbeitet | ebenso |
sammeln, dann removeAll() | korrekt | korrekt |
über clone laufen | korrekt | korrekt |
Die Ursache liegt im eigenen Iterator von PHP SplObjectStorage. offsetUnset() setzt ihn nach dem Löschen auf den Anfang, sodass auch key() von vorn zählt: Im nächsten Durchlauf steht dort wieder 1. Das Löschen über eckige Klammern geht seit PHP 8.2 einen anderen Weg und lässt die Position auf dem gelöschten Eintrag stehen, der nächste Schritt springt dann einen Eintrag zu weit. Dokumentiert ist keines der beiden Verhalten. Für ein Aufräumen, das zuverlässig in jeder Version funktioniert, gibt es zwei Wege, beide im Beispiel oben: erst die betroffenen Objekte in einer zweiten Menge sammeln und nach der Schleife mit removeAll() abziehen, oder über eine Kopie laufen und im Original löschen. Damit der Fehler in Tests auffällt, braucht der Testfall zwei Treffer direkt hintereinander, am besten gleich am Anfang der Liste wie a und b im Beispiel. Ein einzelner Treffer verdeckt ihn in jeder Version.
Dasselbe gilt fürs Hinzufügen. Ein Importskript, das beim Durchlaufen zu jedem Kunden einen Partner nachlädt und in dieselbe Zuordnung setzt, besuchte im Test fast immer jedes neue Objekt sofort mit und wäre ohne Notbremse endlos gelaufen. Mit nur einem Startobjekt endete die Schleife dagegen manchmal schon nach der ersten Runde. Das Ergebnis hängt also von internen Details ab. Neue Objekte gehören in eine eigene Menge, die nach der Schleife mit addAll() übernommen wird:
<?php
$kunden = new SplObjectStorage();
$kunden[new Kunde('Anna')] = null;
$kunden[new Kunde('Ben')] = null;
$neu = new SplObjectStorage();
$runden = 0;
foreach ($kunden as $k) {
$runden++;
$neu[new Kunde($k->name . ' (Partner)')] = null;
}
$kunden->addAll($neu);
echo $runden, ' ', count($kunden), PHP_EOL;
/* 2 4 */
Die Schleife lief genau zweimal, danach standen vier Kunden in der Zuordnung, in allen gemessenen Versionen gleich. Unbedenklich ist in der Schleife nur setInfo() für das aktuelle Objekt.
Mengen bilden: welche Daten bei addAll() gewinnen
Ein CRM soll Zielgruppen bilden: alle Newsletter-Empfänger, alle Käufer, die Empfänger ohne Kauf und die Empfänger, die auch gekauft haben. Mit zwei Mengen aus PHP SplObjectStorage sind das Vereinigung, Differenz und Schnittmenge:
<?php
$anna = new Kunde('Anna');
$ben = new Kunde('Ben');
$cem = new Kunde('Cem');
$newsletter = new SplObjectStorage();
$newsletter[$anna] = 'seit 2024';
$newsletter[$ben] = 'seit 2025';
$kaeufer = new SplObjectStorage();
$kaeufer[$ben] = 'Bestellung 17';
$kaeufer[$cem] = 'Bestellung 18';
$alle = clone $newsletter;
echo $alle->addAll($kaeufer), PHP_EOL;
/* 3 */
echo $alle[$ben], PHP_EOL;
/* Bestellung 17 */
$ohneKauf = clone $newsletter;
echo $ohneKauf->removeAll($kaeufer), PHP_EOL;
/* 1, nur Anna */
$beides = clone $newsletter;
echo $beides->removeAllExcept($kaeufer), PHP_EOL;
/* 1, nur Ben */
echo $beides[$ben], PHP_EOL;
/* seit 2025 */
Alle drei Mengenmethoden von PHP SplObjectStorage verändern die Menge, auf der sie aufgerufen werden. Deshalb arbeitet das Beispiel jeweils auf einem clone, und der Newsletter-Verteiler bleibt danach unverändert bei Anna und Ben. Der Rückgabewert ist die neue Anzahl, nicht die Zahl der hinzugefügten oder entfernten Objekte. Bei den Daten gilt eine klare Regel: addAll() übernimmt für Objekte, die in beiden Mengen stehen, die Daten der hinzugefügten Menge, Ben trägt danach Bestellung 17. removeAllExcept() behält dagegen die eigenen Daten, Ben bleibt seit 2025. removeAll() schaut nur auf die Objekte und ignoriert die Daten der anderen Menge ganz. Ruft man removeAll() mit der Menge selbst auf, ist sie danach leer.
Derselbe Artikel zweimal: getHash() überschreiben
Ein Import liest Artikel aus der Datenbank und aus einer CSV-Datei und soll Dubletten zusammenführen. Artikel 7 kommt dabei als zwei getrennte Objekte an. PHP SplObjectStorage vergleicht standardmäßig nach Identität, intern über spl_object_hash(), und zählt deshalb zwei Einträge. Zwei Objekte mit gleichen Werten sind für die Klasse zwei verschiedene Objekte.
Diese Regel von PHP SplObjectStorage lässt sich ändern. getHash() bestimmt, welche Objekte als gleich gelten, und eine Unterklasse kann daraus eine fachliche Regel machen:
<?php
final class Artikel
{
public function __construct(
public int $id,
public string $titel
) {}
}
final class ArtikelSet extends SplObjectStorage
{
public function getHash(object $o): string
{
return $o::class . '#' . $o->id;
}
}
$korb = new ArtikelSet();
$korb[new Artikel(7, 'Tasse')] = 1;
$korb[new Artikel(7, 'Tasse (neu geladen)')] = 2;
echo count($korb), PHP_EOL;
/* 1 */
foreach ($korb as $a) {
echo $a->titel, ': ', $korb->getInfo();
}
/* Tasse: 2 */
Gemessen blieb ein Eintrag übrig. Gespeichert bleibt das zuerst eingefügte Objekt mit dem Titel Tasse, die Daten stammen vom zweiten Einfügen. Auch isset() mit einem dritten Objekt gleicher ID lieferte true, denn offsetExists() fragt ebenfalls getHash(). Der Schlüssel aus Klassenname und ID verhindert, dass ein Artikel 7 und eine Kategorie 7 zusammenfallen.
Eine Falle liegt im Rückgabetyp. Fehlt die Angabe : string, meldet PHP 8.1 bis 8.5 schon beim Laden der Klasse Deprecated: Return type of ArtikelSet::getHash(object $o) should either be compatible with SplObjectStorage::getHash(object $object): string. Liefert die Methode dann eine Zahl, etwa mit return $o->id;, melden PHP 8.1 bis 8.3 RuntimeException: Hash needs to be a string, PHP 8.4 und 8.5 dagegen TypeError: ArtikelSet::getHash(): Return value must be of type string, int returned. Ein altes catch (RuntimeException $e) greift nach dem Update also nicht mehr. Mit deklariertem : string wandelt PHP ohne strict_types die Zahl still um. Am sichersten ist eine Methode mit festem Rückgabetyp, die den Hash aus Werten bildet, die sich während der Laufzeit nicht ändern.
Speicher, IDs und Cache: PHP SplObjectStorage oder WeakMap?
Ein Queue-Worker merkt sich zu jedem Auftrag Statusdaten und läuft stundenlang. Der Speicherverbrauch steigt, bis der Prozess am memory_limit scheitert, obwohl das Skript die Aufträge längst freigegeben hat. Der Grund ist die Zuordnung selbst: PHP SplObjectStorage hält jedes Objekt mit einer starken Referenz fest, solange es darin steht. WeakMap, verfügbar seit PHP 8.0, hält dagegen schwache Referenzen, ein Eintrag verschwindet mit seinem Objekt:
<?php
$s = new SplObjectStorage();
$w = new WeakMap();
$anna = new Kunde('Anna');
$ben = new Kunde('Ben');
$s[$anna] = 1;
$w[$ben] = 1;
unset($anna, $ben);
echo count($s), ' ', count($w), PHP_EOL;
/* 1 0 */
$cem = new Kunde('Cem');
$s[$cem] = 1;
$w[$cem] = 1;
unset($cem);
echo count($s), ' ', count($w), PHP_EOL;
/* 2 1, Cem bleibt auch in der WeakMap */
$a = new Kunde('Anna');
$rabatt[spl_object_id($a)] = 10;
unset($a);
$b = new Kunde('Ben');
echo $rabatt[spl_object_id($b)] ?? 0;
/* 10, Ben erbt Annas Rabatt */
Unter Linux mit PHP 8.4 belegten 100.000 Auftragsobjekte mit je einem kleinen Array 49,5 MB, zusammen mit PHP SplObjectStorage 53,8 MB. Nachdem das Skript alle eigenen Verweise gelöscht hatte, blieben 51,8 MB belegt, die Zuordnung zählte weiter 100.000 Einträge. Mit WeakMap fiel der Verbrauch auf 10,0 MB, count() meldete 0. PHP 8.1 und 8.5 sowie Windows zeigten dieselbe Größenordnung. Eine Falle steckt im zweiten Teil des Beispiels: Halten eine PHP SplObjectStorage und eine WeakMap dasselbe Objekt, bleibt auch der Eintrag in der WeakMap stehen, weil die starke Referenz das Objekt am Leben hält. Wie man den Verbrauch eines Workers je Auftrag protokolliert, zeigt das Tutorial zu memory_get_usage().
Der dritte Teil des Beispiels zeigt, warum ein Array mit spl_object_id() als Schlüssel keine Alternative ist. Anna bekommt zehn Prozent Rabatt, ihr Objekt wird freigegeben, und das neue Objekt für Ben erhält dieselbe ID und damit Annas Rabatt. spl_object_hash() verhielt sich im Test genauso. Eine ID gilt nur, solange ihr Objekt lebt, danach vergibt PHP sie neu. Sicher ist ein solches Array nur, wenn das Objekt im selben Eintrag als Wert mitgespeichert wird, denn dann lebt es so lange wie der Eintrag.
Auch im Cache verhält sich PHP SplObjectStorage anders als erwartet. Ein Team legt die fertige Rabattliste per serialize() im Cache ab und prüft später mit dem Kundenobjekt der aktuellen Anfrage:
<?php
$anna = new Kunde('Anna');
$rabatte = new SplObjectStorage();
$rabatte[$anna] = 'Rabatt 10';
$geladen = unserialize(serialize($rabatte));
var_dump(count($geladen), isset($geladen[$anna]));
/* int(1)
bool(false) */
echo json_encode($rabatte), PHP_EOL;
/* {} */
$zeilen = [];
foreach ($rabatte as $kunde) {
$zeilen[] = [
'kunde' => $kunde->name,
'rabatt' => $rabatte[$kunde],
];
}
echo json_encode($zeilen);
/* [{"kunde":"Anna","rabatt":"Rabatt 10"}] */
Nach unserialize() stehen in der Liste neue Objekte. Sie sind gleich, aber nicht identisch, und isset() mit dem Original findet nichts. Eine WeakMap lässt sich gar nicht ablegen und endet mit Exception: Serialization of 'WeakMap' is not allowed. Und eine API-Antwort, die eine PHP SplObjectStorage direkt an json_encode() übergibt, liefert nur {}. Für Cache und JSON gehören fachliche Werte in ein Array, die Zuordnung zu Objekten entsteht erst wieder im laufenden Prozess.
Die Unterschiede auf einen Blick, gemessen in PHP 8.1 bis 8.5:
| Eigenschaft | SplObjectStorage | WeakMap | Array mit ID |
| hält das Objekt am Leben | ja | nein | nur mit Objekt als Wert |
isset() bei Daten null | true | false | false |
| fehlendes Objekt lesen | UnexpectedValueException | Error | Warning, null |
| Array als Daten erweitern | nur über Kopie | direkt | direkt |
| eigene Gleichheit | getHash() | nein, Klasse ist final | eigener Schlüssel |
| Mengenoperationen | eingebaut | nein | über Array-Funktionen |
serialize() | ja, mit Kopien | Exception | ja, IDs sinnlos |
Schlüssel im foreach | laufende Nummer | das Objekt | die ID |
Bei der Geschwindigkeit liegen die drei nah beieinander. 100.000 Objekte einzufügen dauerte unter Linux mit PHP 8.4 und 8.5 je nach Lauf 3 bis 30 Millisekunden, 100.000 Suchen 2 bis 12. Die WeakMap brauchte beim Einfügen meist zwei- bis dreimal so lange wie PHP SplObjectStorage, beim Suchen lagen alle drei gleichauf. Die Wahl ist also eine Frage der Lebensdauer. Das Diagramm fasst die Entscheidung zusammen:
flowchart TD
A[Daten zu Objekten speichern]
A --> B{Eintrag soll mit Objekt enden?}
B -->|ja| C[WeakMap]
B -->|nein| D{Gleichheit nach fachlicher ID?}
D -->|ja| E[SplObjectStorage mit getHash]
D -->|nein| F{Mengen bilden?}
F -->|ja| G[SplObjectStorage]
F -->|nein| H[beide geeignet]
A -.-> I[Array mit spl_object_id meiden]
Die erste Frage lautet immer: Soll der Eintrag verschwinden, wenn das Objekt anderswo nicht mehr gebraucht wird? Dann ist WeakMap richtig, typisch für Zwischenspeicher zu fremden Objekten. Werden Mengen gebildet, Objekte nach fachlicher ID gleichgesetzt oder Zuordnungen bewusst festgehalten, passt PHP SplObjectStorage. Im Worker heißt das: jeden Auftrag am Ende mit unset($status[$auftrag]) entfernen, außerhalb jeder Schleife über die Zuordnung. Gemessen war die Zuordnung so nach 100.000 Aufträgen leer und belegte keinen zusätzlichen Speicher, ohne das Entfernen blieben unter Linux wie oben 51,8 MB belegt.
Praxis: Änderungen an Objekten mit PHP SplObjectStorage verfolgen
Ein internes Werkzeug lädt Produkte, lässt sie bearbeiten und speichert am Ende. Gespeichert werden soll nur, was sich wirklich geändert hat, dazu neue und gelöschte Produkte, und das, ohne in jede Klasse einen Änderungsmerker einzubauen. Die folgende Änderungsliste löst das mit drei Instanzen von PHP SplObjectStorage und nutzt dabei fast alles aus den vorigen Abschnitten:
<?php
final class Produkt
{
public function __construct(
public int $id,
public string $name,
public float $preis
) {}
}
final class Aenderungsliste
{
private SplObjectStorage $bekannt;
private SplObjectStorage $neu;
private SplObjectStorage $weg;
public function __construct()
{
$this->bekannt = new SplObjectStorage();
$this->neu = new SplObjectStorage();
$this->weg = new SplObjectStorage();
}
public function geladen(object $o): void
{
$this->bekannt[$o] = get_object_vars($o);
}
public function neu(object $o): void
{
$this->neu[$o] = null;
}
public function loesche(object $o): void
{
if (isset($this->neu[$o])) {
unset($this->neu[$o]);
return;
}
$this->weg[$o] = null;
}
/** @return list<object> */
public function geaendert(): array
{
$liste = [];
foreach ($this->bekannt as $o) {
$vorher = $this->bekannt->getInfo();
if (isset($this->weg[$o])) {
continue;
}
if (get_object_vars($o) !== $vorher) {
$liste[] = $o;
}
}
return $liste;
}
public function abschliessen(): void
{
$this->bekannt->removeAll($this->weg);
$this->bekannt->addAll($this->neu);
foreach ($this->bekannt as $o) {
$stand = get_object_vars($o);
$this->bekannt->setInfo($stand);
}
$this->neu = new SplObjectStorage();
$this->weg = new SplObjectStorage();
}
public function bericht(): string
{
return sprintf(
'neu %d, geändert %d, gelöscht %d',
count($this->neu),
count($this->geaendert()),
count($this->weg)
);
}
}
$tasse = new Produkt(1, 'Tasse', 9.90);
$teller = new Produkt(2, 'Teller', 14.50);
$kanne = new Produkt(3, 'Kanne', 29.00);
$liste = new Aenderungsliste();
foreach ([$tasse, $teller, $kanne] as $p) {
$liste->geladen($p);
}
$tasse->preis = 8.90;
$liste->loesche($kanne);
$glas = new Produkt(4, 'Glas', 4.50);
$liste->neu($glas);
$entwurf = new Produkt(5, 'Entwurf', 1.00);
$liste->neu($entwurf);
$liste->loesche($entwurf);
echo $liste->bericht(), PHP_EOL;
/* neu 1, geändert 1, gelöscht 1 */
foreach ($liste->geaendert() as $p) {
echo 'UPDATE ', $p->name, PHP_EOL;
}
/* UPDATE Tasse */
$liste->abschliessen();
echo $liste->bericht(), PHP_EOL;
/* neu 0, geändert 0, gelöscht 0 */
$glas->name = 'Weinglas';
echo $liste->bericht(), PHP_EOL;
/* neu 0, geändert 1, gelöscht 0 */
Beim Laden legt geladen() einen Schnappschuss der öffentlichen Felder als Daten in der PHP SplObjectStorage ab. Ob sich ein Objekt geändert hat, entscheidet später der Vergleich mit diesem Schnappschuss. Die Schleife in geaendert() liest nur und verändert die Zuordnung nicht, sie ist damit unbedenklich. Neue und gelöschte Objekte stehen in zwei eigenen Mengen. Wird ein Objekt gelöscht, das noch gar nicht gespeichert war, wie der Entwurf im Beispiel, verschwindet es einfach aus der Menge der neuen; so landet kein unnötiger Löschbefehl in der Datenbank.
Den Abschluss übernehmen die Mengenmethoden. removeAll() zieht die gelöschten Objekte ab, addAll() nimmt die neuen auf, und setInfo() erneuert die Schnappschüsse, ohne die Struktur zu verändern. Gemessen lautete der Bericht zuerst neu 1, geändert 1, gelöscht 1 mit der Zeile UPDATE Tasse, nach dem Abschluss überall 0 und nach dem Umbenennen des Glases wieder geändert 1. Eine Grenze hat das Verfahren: get_object_vars() sieht von außerhalb der Klasse nur öffentliche Properties, im Test fehlten private und geschützte. Für Datenobjekte mit öffentlichen Feldern reicht das, sonst muss die Klasse ihren Zustand selbst liefern.
Ein zweites, kürzeres Muster aus dem Alltag ist die Besucht-Liste beim rekursiven Durchlaufen. Seiten, Menüs oder Kategorien verweisen oft im Kreis aufeinander, und eine naive Rekursion läuft dann bis zum Speicherende. Eine Menge besuchter Objekte beendet den Kreis:
<?php
final class Knoten
{
public array $kinder = [];
public function __construct(
public string $name
) {}
}
final class Besuchsliste
{
public function __construct(
private SplObjectStorage $gesehen
= new SplObjectStorage()
) {}
public function merke(object $o): bool
{
if (isset($this->gesehen[$o])) {
return false;
}
$this->gesehen[$o] = true;
return true;
}
}
function zeige(
Knoten $k,
Besuchsliste $b,
int $tiefe = 0
): void {
$zeile = str_repeat(' ', $tiefe) . $k->name;
if (!$b->merke($k)) {
echo $zeile, ' (schon besucht)', PHP_EOL;
return;
}
echo $zeile, PHP_EOL;
foreach ($k->kinder as $kind) {
zeige($kind, $b, $tiefe + 1);
}
}
$start = new Knoten('Start');
$shop = new Knoten('Shop');
$konto = new Knoten('Konto');
$start->kinder = [$shop, $konto];
$shop->kinder = [$konto];
$konto->kinder = [$start];
zeige($start, new Besuchsliste());
/* Start
Shop
Konto
Start (schon besucht)
Konto (schon besucht) */
Die Klasse Besuchsliste zeigt nebenbei, wie eine PHP SplObjectStorage als Eigenschaft vorbelegt wird, hier private, mit protected geht es genauso. Der naheliegende Weg, protected SplObjectStorage $gesehen = new SplObjectStorage(); direkt an der Eigenschaft, endet in PHP 8.1 bis 8.5 mit Fatal error: New expressions are not supported in this context. Als Standardwert eines Konstruktorparameters ist new dagegen seit PHP 8.1 erlaubt. Die Methode merke() enthält außerdem die Prüfung gegen verlorene Daten: Ein Objekt, das schon drin ist, wird nicht noch einmal gesetzt.
Fazit
PHP SplObjectStorage löst ein Problem, an dem Arrays scheitern: Daten zu Objekten speichern und Mengen von Objekten bilden. Die Array-Schreibweise mit $s[$o], isset() und unset() läuft in PHP 8.1 bis 8.5 ohne Meldung, während attach(), detach() und contains() ab 8.5 als veraltet gemeldet werden.
Die eigentlichen Fehler mit PHP SplObjectStorage passieren still. Erneutes Setzen ohne Daten löscht die Daten, Arrays als Daten lassen sich nicht direkt erweitern, Löschen im foreach überspringt oder wiederholt Objekte, je nach Version anders, und zwei verschachtelte Schleifen teilen sich einen Zeiger. Wer in der Schleife nur liest, Änderungen sammelt und danach mit removeAll() oder addAll() anwendet, umgeht das in jeder Version. Für Zwischenspeicher zu fremden Objekten ist WeakMap die bessere Wahl, weil sie Objekte freigibt. Ein Array mit spl_object_id() ist es nie, denn PHP vergibt die IDs neu.