Iterating Objects with foreach (Iterator & IteratorAggregate)
debt(d7/e3/b3/t7)
Closest to 'only careful code review or runtime testing' (d7). PHPStan (the listed tool) can catch some signature issues and the code_pattern for hand-rolled Iterator, but the real bugs — failing to reset rewind() state, shared-pointer corruption in nested loops, returning a non-Traversable from getIterator() — are silent at compile time and only surface during runtime testing or careful review. Slightly worse than d5 because the behavioural traps aren't reliably caught by a static analyzer.
Closest to 'simple parameterised fix' (e3). The quick_fix is to swap a hand-rolled five-method Iterator for IteratorAggregate returning a generator — a contained pattern replacement within one collection class, not a one-line swap but well short of a multi-file refactor.
Closest to 'localised tax' (b3). The iteration contract lives inside the collection class; though it applies across web/cli/queue/library contexts, consumers only see foreach. One component pays the implementation cost while the rest of the codebase is unaffected.
Closest to 'serious trap' (t7). The misconception — that foreach only works on arrays so collections must expose internal arrays — directly contradicts how Traversable objects behave, and the stateful Iterator pointer (shared instance corrupting nested loops, missing rewind reset) contradicts the stateless expectation developers have from iterating arrays. The 'obvious' way of reusing one Iterator instance is wrong.
Also Known As
TL;DR
Explanation
When you write foreach ($obj as $key => $value) on an object, PHP picks one of three strategies. If the class implements Iterator, PHP calls its five methods in order: rewind(), then a loop of valid(), current(), key(), next(). If the class implements IteratorAggregate, PHP calls getIterator(), which must return a Traversable (often an inner ArrayIterator or a generator). If the class implements neither, foreach falls back to iterating the object's public properties in declaration order — useful for plain DTOs but a leaky abstraction for encapsulated classes.
Iterator gives you full manual control: you decide what 'current' means, how keys advance, and when iteration ends. This is ideal for streaming data, lazy database cursors, or computed sequences where you do not want to materialise everything into an array. The downside is boilerplate — five methods to maintain a cursor position correctly, including the easy-to-forget rewind() that must reset state so the same object can be looped twice.
IteratorAggregate is almost always the better choice for collection classes. You implement a single getIterator() method and delegate to a generator or ArrayIterator, getting correct iteration semantics for free. Since PHP 5.5, returning a generator from getIterator() is the cleanest pattern: yield each element and PHP handles the cursor mechanics.
A common subtlety: iterating an Iterator object inside nested foreach loops over the same instance shares cursor state and breaks, because there is one internal pointer. Generators and IteratorAggregate returning fresh iterators avoid this. Also note that foreach over an object's properties only sees properties visible from the calling scope, so private properties are hidden when iterating from outside the class. Prefer explicit Traversable implementations over relying on property iteration for anything beyond trivial value objects.
Common Misconception
Why It Matters
Common Mistakes
- Forgetting to reset state in rewind(), so the object cannot be iterated a second time.
- Implementing Iterator with hand-rolled cursor methods when IteratorAggregate returning a generator would be simpler and correct.
- Reusing a single Iterator instance in nested foreach loops, where the shared internal pointer corrupts iteration.
- Relying on foreach iterating public properties as a public API, which breaks encapsulation and changes with property visibility.
- Returning a non-Traversable value from getIterator(), which triggers an exception at iteration time.
Code Examples
// Hand-rolled Iterator with a forgotten rewind reset
class UserCollection implements Iterator {
private array $users;
private int $pos = 0;
public function __construct(array $users) {
$this->users = $users;
}
public function current(): mixed { return $this->users[$this->pos]; }
public function key(): mixed { return $this->pos; }
public function next(): void { $this->pos++; }
public function valid(): bool { return isset($this->users[$this->pos]); }
// rewind does nothing — second foreach yields nothing
public function rewind(): void {}
}
$c = new UserCollection(['a', 'b']);
foreach ($c as $u) { /* works once */ }
foreach ($c as $u) { /* broken: pos never reset */ }
// IteratorAggregate delegating to a generator — concise and correct
class UserCollection implements IteratorAggregate {
public function __construct(private array $users) {}
public function getIterator(): Traversable {
foreach ($this->users as $key => $user) {
yield $key => $user;
}
}
}
$c = new UserCollection(['a', 'b']);
foreach ($c as $u) { /* works */ }
foreach ($c as $u) { /* works again: fresh generator each time */ }