PHP generators & iterators
How foreach iterates an object
foreach does not read an object's properties directly. It looks for the Traversable marker interface. You cannot implement Traversable in userland, so you implement one of its two children instead.
Iterator makes the object drive its own iteration through five methods: current(), key(), next(), rewind(), valid(). foreach ($it as $k => $v) calls them in a fixed order:
$it->rewind(); // once, at the start
while ($it->valid()) { // false stops the loop
$v = $it->current();
$k = $it->key();
// loop body
$it->next(); // advance, then back to valid()
}
IteratorAggregate is the shortcut: implement one method, getIterator(): Traversable, and hand back a ready-made iterator, often an ArrayIterator or a generator. The object delegates rather than iterating itself.
class Bag implements IteratorAggregate {
private array $items = ['a', 'b', 'c'];
public function getIterator(): Iterator {
return new ArrayIterator($this->items);
}
}
foreach (new Bag() as $x) { /* a, b, c */ }
A plain array is iterable yet is not instanceof Traversable. To ask "can I foreach this?", use is_iterable($x) or the iterable type (array|Traversable), both since PHP 7.1.
Why generators exist: streaming instead of arrays
A function with yield in its body is a generator function. Calling it returns a Generator and runs none of the body yet. The body advances one yield at a time and keeps its local state between steps.
function xrange($start, $limit, $step = 1) {
for ($i = $start; $i <= $limit; $i += $step) {
yield $i; // one value at a time, computed on demand
}
}
foreach (xrange(1, 1_000_000) as $n) { /* constant memory */ }
range(0, 1_000_000) builds the whole array first and costs well over 100 MB. The generator holds only its call frame and the current value, under 1 KB, for the same output. Reach for a generator whenever you stream a large file, a DB result set, or a wide range and touch each item once.
yield keys and the single-pass rule
Default keys auto-increment like a list. Override them with yield $key => $value:
foreach ($rows as $row) {
yield $row['id'] => $row; // your own keys, not 0, 1, 2
}
A generator is forward-only and runs once. After you have iterated it, a second foreach throws, because it tries to rewind() a generator that has already started. To iterate twice, call the generator function again for a fresh Generator, or use a rewindable ArrayIterator.
Wrong "I'll count() the generator to size the batch." A generator is not Countable and has no array access, so count($gen) and $gen[0] both fail. Materialize it with iterator_to_array($gen) first if you need a size or random access, accepting that this pulls every value into memory.
send(), coroutines, and auto-priming
yield is an expression whose value is whatever send() passes in. Generator::send($value) sets $value as the result of the currently paused yield, resumes the body, and returns the next yielded value, or null when the generator finishes without yielding again. That two-way flow turns a generator into a lightweight coroutine.
function printer() {
while (true) {
$line = yield; // receives the sent value
echo $line . PHP_EOL;
}
}
$p = printer();
$p->send('hello'); // prints hello
$p->send('bye'); // prints bye
PHP auto-primes. If the generator is not yet parked at a yield, the first send() advances it to the first yield before delivering the value, so no manual priming call is needed. Python instead requires the first send() to be None.
Wrong "send($v) returns $v." It returns the next yielded value, not the argument. Also avoid driving a coroutine with foreach while calling send(): both advance the pointer, so foreach skips the values you send past.
yield from key collisions and captured returns
yield from <iterable> splices a generator, Traversable, or array into the outer generator. It does not renumber keys, so integer-keyed inner sources collide with the outer sequence.
function inner() { yield 1; yield 2; yield 3; } // keys 0, 1, 2
function gen() {
yield 0; // outer key 0
yield from inner(); // keys 0, 1, 2 again
yield 4; // outer key 1
}
var_dump(iterator_to_array(gen()));
// array(3) { [0] => 1, [1] => 4, [2] => 3 } duplicate keys clobbered
iterator_to_array() defaults preserve_keys to true, so clashing keys overwrite earlier values and two of the five drop out. Pass iterator_to_array(gen(), false) to reindex and keep all five. Separately, yield from evaluates to the inner generator's getReturn(), so $ret = yield from sub(); composes coroutine pipelines by capturing a sub-generator's return.
The no-rewind rule and single-pass failures
Once a generator advances past its first yield, rewind() throws "Cannot rewind a generator that was already run". Because foreach calls rewind() at its start, a second foreach over an already-iterated generator throws, as does handing a partly consumed generator to code that rewinds it. A fresh, not-yet-started generator can still be primed. For multiple passes, rebuild the generator by calling the function again, or materialize once with iterator_to_array().
Generator::getReturn() reads a generator's return value, but only after it finishes. Calling it earlier throws "Cannot get return value of a generator that hasn't returned"; a generator that ends without an explicit return gives null.
Iterator versus IteratorAggregate, and when SPL wins
Choose IteratorAggregate when you can delegate to an existing iterator or a yield from; choose Iterator when you need manual control of position and state. A generator collapses a forty-line Iterator class into a few lines, at the cost of being forward-only, single-pass, and not Countable. When you need rewind(), count(), seek(), or repeated passes, an ArrayIterator holds the full array and supports all of them. Since PHP 8.5, constructing an ArrayIterator from an object's properties is deprecated; use yield from or get_object_vars() instead. Userland current() and key() returning mixed may need #[\ReturnTypeWillChange] to silence a tentative-return-type deprecation, as the manual's own Iterator example shows.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.