Iterator and IteratorAggregate

foreach over an object walks its public properties unless the class implements Traversable through one of two children. Iterator makes the object its own cursor with five methods; IteratorAggregate has one, getIterator(), which returns any iterator, most simply a generator (Generators and yield). The traced class prints the order in which foreach calls the five.

The foreach protocol, traced, and the one-method alternativePHP
<?php
final class TracedLines implements Iterator {
  private int $i = 0;
  public function __construct(private array $lines) {}
  public function rewind(): void { echo 'rewind '; $this->i = 0; }
  public function valid(): bool { echo 'valid '; return $this->i < count($this->lines); }
  public function current(): mixed { echo 'current '; return $this->lines[$this->i]; }
  public function key(): mixed { echo 'key '; return "L{$this->i}"; }
  public function next(): void { echo "next\n"; $this->i++; }
}
foreach (new TracedLines(['BK-PHP-01', 'BK-SQL-01']) as $k => $sku) echo "=> $k $sku, ";
final class Order implements IteratorAggregate {
  public function __construct(private array $lines) {}
  public function getIterator(): Generator {
    foreach ($this->lines as $sku => [$qty, $cents]) yield $sku => $qty * $cents;
  }
}
$order = new Order(['BK-PHP-01' => [2, 3990], 'BK-SQL-01' => [1, 4450]]);
echo "(end)\n", json_encode(iterator_to_array($order)), "\n";
Output
rewind valid current key => L0 BK-PHP-01, next
valid current key => L1 BK-SQL-01, next
valid (end)
{"BK-PHP-01":7980,"BK-SQL-01":4450}

Each pass runs valid(), current(), key(), the loop body and next(), and a valid() that returns false ends the loop. Prefer IteratorAggregate: it is shorter, and each foreach gets a fresh iterator, so nested loops over one order do not share a position.