Generators — send(), throw(), and Pipelines
Understand how a generator can receive values back via send(), how throw() injects an exception at a generator's suspended yield, the practical differences between generators and SPL iterators and between generators and Fibers, and how to compose small generator functions into lazy, memory-efficient data-processing pipelines.
The first Generators lesson covered yield, yield from, and getReturn() — enough to write a lazy producer that a plain foreach can consume. This lesson goes further into the parts of Generator that turn it into more than a one-way producer: send(), which lets a caller push values back into a paused generator; throw(), which lets a caller inject an exception at that same paused point; and the discipline of chaining several small generator functions together into a composable, entirely lazy processing pipeline — the pattern real ETL tools and stream processors are built on.
yield as a two-way channel: Generator::send() in depth
$x = yield $value; does two things at once: it produces $value out to whoever is iterating the generator, and it pauses, waiting to receive whatever is later sent back in — that received value becomes the result of the yield expression itself, assigned to $x. Calling $generator->send($input) resumes a paused generator and delivers $input as exactly that value.
There is a well-documented gotcha: if send() is called on a generator that hasn't been advanced to its first yield yet, PHP first silently advances it to that first yield (as if you had called current()) before delivering the value — which means the argument passed to that very first send() call is effectively discarded, because there was no yield expression yet actually waiting for it. The standard fix is to explicitly 'prime' the generator with a call to $generator->current() (or ->rewind()) before the first real send(), so the intent is visible in the code rather than relying on an implicit priming step.
• yield $value; — produces $value, and evaluates to null unless a later send() is issued to it. • $x = yield $value; — yield as an expression; $x receives whatever the next send() call passes in. • $generator->send($input) — resumes the generator, delivering $input as the value of the paused yield expression. • Prime a fresh generator with ->current() before the first real send() so its argument isn't silently discarded.
Generator::throw(): injecting an exception at the suspended yield
$generator->throw($exception) resumes a generator exactly like send() does, except the paused yield expression throws $exception instead of evaluating to a value. If the generator has wrapped that yield in a try/catch, it can react — clean up state, log the failure, or convert it into its own graceful return value via getReturn(); left uncaught, the exception propagates straight out of the throw() call, just like any uncaught exception propagating out of a function call.
This gives a generator-based pipeline stage a way to signal 'stop processing, something went wrong upstream' without the consumer having to special-case a sentinel value — the same cancellation-by-exception pattern Fiber::throw() provides for Fibers, applied to a generator's narrower job of producing a sequence of values instead of suspending arbitrary control flow.
Generators vs SPL Iterators: two ways to implement Iterator
A generator function is the lightest possible way to get an Iterator-compatible object: write ordinary control flow with yield sprinkled in, and PHP handles current()/key()/next()/valid()/rewind() for you. A hand-written class implementing Iterator (or one of SPL's ready-made iterators, like ArrayIterator) gives you explicit, testable control over each of those five methods independently, at the cost of noticeably more code.
The most consequential practical difference is reuse: a Generator is forward-only and single-use — once fully iterated (or partially consumed via send()/throw()), it cannot be rewound, and a second foreach over the same instance silently produces nothing. A class-based Iterator you write yourself, or an SPL class like ArrayIterator/ArrayObject, can be made properly rewindable (ArrayIterator genuinely is), so it can be iterated more than once — which matters if the same sequence needs to be consumed by more than one piece of code.
• Generator: minimal code, forward-only, single-use, cannot be rewound past its start. • Class-based / SPL Iterator: more code to write, but rewindable and reusable across multiple foreach loops. • Both satisfy the same Iterator/Traversable contract, so calling code (a plain foreach) can't tell which one it received.
Generators vs Fibers: reiterating the precise distinction
A generator can only pause at a yield written directly inside its own function body; a Fiber can pause from any depth of nested calls inside the callable it's running, because it preserves an entire separate call stack rather than one function's local state. A generator's job is producing a sequence of values, one at a time, through the standard Iterator interface; a Fiber's job is suspending arbitrary control flow — most often to wait on external I/O — and is driven through its own start()/resume()/throw() API, not foreach.
Historically, PHP developers simulated coroutine-style async code using generators (a yield-based 'cooperative multitasking' pattern) before Fibers existed, because a generator's suspend point could at least stand in for 'this is waiting on something.' Fibers, added in 8.1, are the more general and more appropriate primitive for that job now — reach for a generator when the goal is genuinely representing a lazy sequence of data, and reach for (or rely on a library built on) a Fiber when the goal is suspending control flow for asynchronous I/O.
Composing generators into lazy pipelines
Small, single-purpose generator functions — a mapGen() that transforms each value, a filterGen() that keeps only matching values, a takeGen() that stops after a limit — can be layered around a base source generator to build a pipeline, the same way Unix shell pipes chain small single-purpose programs. Because each stage is itself a generator, wrapping stage after stage never materializes an intermediate array: a value produced by the source flows through every stage of the pipeline, one at a time, before the next value is even requested from the source.
This differs meaningfully from chaining array_map()/array_filter() calls, which are eager: array_map(fn, array_filter($data, pred)) fully computes the filtered array before array_map even starts, and fully computes the mapped array before returning anything — two full passes, two full intermediate arrays. A generator pipeline does the equivalent work in a single logical pass, and can even process an infinite source (like a generator of natural numbers), which an eager array-based approach could never do at all.
Memory-efficiency, revisited with actual numbers
The first Generators lesson made the memory case qualitatively; it's worth confirming it quantitatively using memory_get_usage(). Building a full PHP array of, say, 200,000 computed values allocates memory proportional to that whole array up front — every element, plus PHP's internal hash-table bookkeeping for the array, exists simultaneously. Draining the equivalent generator through a foreach that only accumulates a running result (like a sum) never holds more than roughly one value's worth of extra memory at a time, regardless of how many values the generator ultimately produces.
This is the concrete, measurable reason generator pipelines scale to inputs an eager array-based approach simply cannot handle — a 50-million-row CSV file processed through a generator pipeline uses roughly the same memory as a 500-row one; the eager array equivalent would need memory proportional to the larger file's full size.
Code Examples
Each send($number) resumes the generator, delivers $number as the value of the paused yield $avg expression, and the generator immediately recomputes and yields back the new running average — turning it into a stateful, interactive accumulator rather than a one-shot producer. Priming with ->current() first makes sure the very first send() actually lands on a real, waiting yield. Expected result: 10 15 20 $number = yield $avg;
Common mistake: Calling $gen->send(10) as the very first operation on a freshly created generator, without priming it first: PHP silently advances the generator to its first yield before delivering the value, so that first sent value never reaches the code that (naively) expected it.
throw() resumes the generator exactly like send() does, but the paused yield expression throws the given exception at that point inside the generator's own code instead of returning a value — letting the generator's try/catch clean up and still produce a meaningful getReturn() value, the same cancellation pattern Fiber::throw() provides for Fibers. Expected result: cancelled after processing 2: user aborted Array ( [0] => a [1] => b )
$gen->throw(new \RuntimeException('user aborted'));
naturalNumbers() is an infinite generator, yet the whole pipeline still terminates cleanly: takeGen() only pulls as many values as it needs, and each one flows through mapGen() then filterGen() then takeGen() before the next value is even requested from naturalNumbers() — the program never attempts to square or filter 'all' natural numbers, only exactly as many as the consumer actually asks for. Expected result: 4 16 36 64
$pipeline = takeGen(filterGen(mapGen(naturalNumbers(), ...), ...), 4);
Materializing 200,000 squared integers into $eager allocates memory proportional to the whole array up front; draining the same sequence through lazySquares() in a foreach only ever holds one squared value (plus small generator bookkeeping) at a time, so the lazy pass consistently grows memory usage far less than the eager array allocation did, for the identical computed total. Expected result: lazy used less memory sum=2666686666700000 foreach (lazySquares(200_000) as $square) { $sum += $square; }
function average(): Generator
{
$total = 0;
$count = 0;
$avg = null;
while (true) {
$number = yield $avg;
if ($number === null) {
break;
}
$total += $number;
$count++;
$avg = $total / $count;
}
return $avg;
}
$gen = average();
$gen->current(); // prime the generator up to its first yield
echo $gen->send(10) . PHP_EOL;
echo $gen->send(20) . PHP_EOL;
echo $gen->send(30) . PHP_EOL;
$gen->send(null); // ends the loop; generator terminatesfunction processUntilCancelled(): Generator
{
$processed = [];
try {
while (true) {
$item = yield;
$processed[] = $item;
}
} catch (\RuntimeException $e) {
echo 'cancelled after processing ' . count($processed) . ": {$e->getMessage()}" . PHP_EOL;
}
return $processed;
}
$gen = processUntilCancelled();
$gen->current(); // prime
$gen->send('a');
$gen->send('b');
$gen->throw(new \RuntimeException('user aborted'));
print_r($gen->getReturn());function mapGen(iterable $source, callable $fn): Generator
{
foreach ($source as $key => $value) {
yield $key => $fn($value);
}
}
function filterGen(iterable $source, callable $predicate): Generator
{
foreach ($source as $key => $value) {
if ($predicate($value)) {
yield $key => $value;
}
}
}
function takeGen(iterable $source, int $limit): Generator
{
$i = 0;
foreach ($source as $key => $value) {
if ($i++ >= $limit) {
return;
}
yield $key => $value;
}
}
function naturalNumbers(): Generator
{
$n = 1;
while (true) {
yield $n++;
}
}
$pipeline = takeGen(
filterGen(
mapGen(naturalNumbers(), fn (int $n) => $n * $n),
fn (int $square) => $square % 2 === 0,
),
4,
);
foreach ($pipeline as $square) {
echo $square . ' ';
}function eagerSquares(int $count): array
{
$out = [];
for ($i = 1; $i <= $count; $i++) {
$out[] = $i * $i;
}
return $out;
}
function lazySquares(int $count): Generator
{
for ($i = 1; $i <= $count; $i++) {
yield $i * $i;
}
}
$before = memory_get_usage();
$eager = eagerSquares(200_000);
$eagerGrowth = memory_get_usage() - $before;
unset($eager);
$beforeLazy = memory_get_usage();
$sum = 0;
foreach (lazySquares(200_000) as $square) {
$sum += $square;
}
$lazyGrowth = memory_get_usage() - $beforeLazy;
echo ($lazyGrowth < $eagerGrowth ? 'lazy used less memory' : 'lazy used more memory') . PHP_EOL;
echo 'sum=' . $sum . PHP_EOL;A lazy generator pipeline pulling one element at a time
Pipeline stages are pulled, not pushed: the consumer's foreach drives takeGen(), which drives filterGen(), which drives mapGen(), which drives source() — one value at a time, all the way down and back up the chain.
source() --yield--> mapGen() --yield--> filterGen() --yield--> takeGen() --yield--> foreach consumer
Each value flows all the way through source -> map -> filter -> take -> consumer
before the NEXT value is even requested from source() -- nothing is buffered
between stages, and an infinite source() is safe as long as some stage
(like takeGen()) eventually stops asking for more.Important Rules
yield is an expression: $x = yield $value; both produces $value and, on the next send(), receives a value into $x.
Prime a freshly created generator with ->current() (or ->rewind()) before the first real send(), so the send's argument isn't silently discarded.
throw($exception) makes the paused yield expression throw; the generator's own try/catch can handle it and still produce a getReturn() value.
A Generator is forward-only and single-use — prefer a rewindable SPL/class-based Iterator when the same sequence must be consumed more than once.
Chain small, single-purpose generator functions (map/filter/take-style) instead of one large loop, to build a lazy, composable pipeline.
A generator can only pause at a yield in its own body; reach for a Fiber (or a library built on one) when you need to suspend from any call depth.
Where this shows up
ETL and log-processing tools chain generator functions (parse -> validate -> transform -> batch) into a single lazy pass over files far larger than available memory; coroutine-style libraries predating widespread Fiber adoption used send() to implement request/response-style two-way generator protocols; and stream-processing code commonly uses throw() to propagate an upstream failure (a malformed record, a closed connection) into a generator-based pipeline stage without needing a special sentinel 'error' value mixed into the normal data stream.
$gen = makeAccumulator(); $gen->send(1); // first call on a freshly created generator, unprimed // the value 1 never reaches the first yield expression as expected
$gen = makeAccumulator(); $gen->current(); // explicitly prime to the first yield $gen->send(1); // now reaches the waiting yield correctly
PHP silently advances an unprimed generator to its first yield before delivering a send()'d value, discarding that first argument in the process. Calling ->current() (or ->rewind()) first makes the priming step explicit instead of relying on this easy-to-miss implicit behavior.
$gen = buildPipeline(); foreach ($gen as $v) { /* ... */ } foreach ($gen as $v) { /* silently produces nothing */ }
$results = iterator_to_array(buildPipeline()); // if you truly need to consume it twice and it's small enough to hold in memory // or: call buildPipeline() again for a fresh Generator instance
A Generator is single-use: once fully iterated, a second foreach over the same instance yields nothing (and PHP throws if you try to rewind() past the start). If a sequence genuinely needs to be consumed more than once, either build a fresh generator each time or materialize it into an array when its size makes that safe.
What happens if you call $generator->send($value) as the very first operation on a freshly created generator, before priming it? A. It throws an exception immediately B. PHP first silently advances the generator to its first yield, and the argument passed to that first send() call is discarded C. $value is stored and delivered to the second yield instead D. It behaves identically to calling ->current() followed by send()
B. PHP first silently advances the generator to its first yield, and the argument passed to that first send() call is discarded — Calling send() on an unprimed generator makes PHP implicitly advance it to the first yield first (as if current() had been called), so the value passed to that very first send() call never reaches a waiting yield expression and is effectively discarded.
What does $generator->throw($exception) do? A. It throws $exception immediately at the call site, outside the generator B. It resumes the generator and makes its paused yield expression throw $exception, letting the generator's own try/catch respond C. It is only valid before the generator has started D. It silently discards $exception and resumes normally
B. It resumes the generator and makes its paused yield expression throw $exception, letting the generator's own try/catch respond — throw() resumes a suspended generator exactly like send() does, except the paused yield expression throws the given exception at that point inside the generator's code — the generator can catch and handle it, or let it propagate out of the throw() call if uncaught.
Which statement best distinguishes a Generator from a Fiber? A. A Generator can pause at any call depth; a Fiber can only pause in its own body B. A Generator can only pause at a yield in its own function body, while a Fiber can pause from any depth of nested calls inside it C. Fibers implement the Iterator interface; Generators do not D. There is no meaningful difference; Fiber is simply a newer name for Generator
B. A Generator can only pause at a yield in its own function body, while a Fiber can pause from any depth of nested calls inside it — A generator's yield must be written directly inside its own function body, so it can only pause itself. A Fiber preserves an entire separate call stack, so it can be suspended from arbitrary depth inside nested function calls, which is why Fibers (not generators) are the right primitive for suspending general control flow.
What is the main practical advantage a hand-written or SPL class-based Iterator has over a generator? A. It always uses less memory than a generator B. It can be rewound and iterated more than once, unlike a Generator, which is forward-only and single-use C. It runs in true parallel with other code D. It requires no code to implement
B. It can be rewound and iterated more than once, unlike a Generator, which is forward-only and single-use — A Generator is forward-only and single-use: once fully iterated, a second foreach over the same instance yields nothing. A properly implemented class-based Iterator (or a rewindable SPL iterator like ArrayIterator) can be iterated more than once, which matters whenever the same sequence needs to be consumed by more than one piece of code.
Why does a generator pipeline built from mapGen()/filterGen()/takeGen() avoid intermediate arrays, unlike chaining array_map()/array_filter()? A. Because generator functions run in a separate thread B. Because each stage yields one value at a time, so a value flows through the entire chain before the next one is even requested from the source, instead of each stage fully computing its own array first C. Because array_map() and array_filter() are deprecated D. There is no real difference in behavior between the two approaches
B. Because each stage yields one value at a time, so a value flows through the entire chain before the next one is even requested from the source, instead of each stage fully computing its own array first — array_map()/array_filter() are eager: each call fully computes and returns its own array before the next call even starts. A generator pipeline pulls one value at a time through every stage, so no intermediate array is ever built, which is also what lets it safely process an infinite source as long as something downstream (like takeGen()) eventually stops asking for more.
Write a generator function runningMax(): Generator that, via send(), accepts a stream of numbers one at a time and yields back the highest number seen so far after each one. Prime it, feed it the numbers 3, 7, 2, 9, 4 with successive send() calls, and print each yielded running maximum.
function runningMax(): Generator { $max = null; while (true) { $number = yield $max; if ($number === null) { break; } $max = $max === null ? $number : max($max, $number); } } $gen = runningMax(); $gen->current(); foreach ([3, 7, 2, 9, 4] as $n) { echo $gen->send($n) . ' '; } // 3 7 7 9 9
Keep a single $max variable (starting at null), update it with max($max, $number) only when $number !== null, and yield $max at the top of an infinite while(true) loop, just like the running-average example.