Коли клас не реалізує Traversable, foreach не спирається на жоден контракт обходу, а просто перебирає властивості, видимі в поточній області: ззовні це public-поля, зсередини методу того ж класу - ще й приватні. Сам Traversable з userland-коду реалізувати не можна, це маркер, тож вибір зводиться до двох нащадків. Iterator вимагає пʼяти методів (current, key, next, rewind, valid) і змушує тримати позицію обходу в самому обʼєкті. IteratorAggregate обмежується одним getIterator(): Traversable, який повертає готовий ітератор, і всю механіку віддає йому.
За замовчуванням беріть IteratorAggregate, а Iterator - коли позиція обходу справді є частиною стану обʼєкта. Якщо всередині вже лежить масив, питання закриває один рядок: yield from $this->items або new ArrayIterator($this->items). Генератор тут дає бонус: IteratorAggregate викликає getIterator() на кожен новий foreach, тому щоразу народжується свіжий генератор і повторний обхід не падає з Cannot traverse an already closed generator. Ручний Iterator виправданий у курсорах над потоком, парсерах і всюди, де rewind() має нетривіальний сенс, скажімо перечитати файл з початку. І пильнуйте key(): якщо ключі повторюються, iterator_to_array() мовчки склеїть елементи, поки не передати другим аргументом false.
Countable виглядає дрібницею, поки не згадати зміну поведінки. У PHP 5 і 7.0-7.1 count() на звичайному обʼєкті повертав 1, у 7.2 почав попереджати, а з 8.0 сигнатура функції - count(Countable|array $value), і невідповідність дає TypeError. На булевий контекст інтерфейс при цьому не впливає. Порожня колекція з count() === 0 в if ($collection) усе одно істинна, а empty($collection) дає false, бо обʼєкт у PHP завжди truthy і перевизначити це нічим. Тому count($cart) === 0 або власний isEmpty(), і ніяких скорочень.
JsonSerializable - найбільш прикладний з пʼятірки, бо стандартна серіалізація обʼєкта бере лише публічні властивості. Приватні поля з конструктора зникають, DateTimeImmutable розгортається у {"date": "...", "timezone_type": 3, "timezone": "..."}, а порядок ключів у відповіді диктується порядком оголошення властивостей, а не контрактом API. Метод jsonSerialize() повертає mixed, найзручніше - асоціативний масив: json_encode() пройде по ньому рекурсивно й обробить вкладені обʼєкти, які теж реалізують інтерфейс. Спотикаються тут постійно на двох речах: порожній масив стає [], а не {} (лікується приведенням (object)), і json_encode() без JSON_THROW_ON_ERROR при помилці мовчки повертає false замість винятку.
З PHP 8.0 клас отримує Stringable автоматично, щойно в ньому оголошено __toString(), тому явний implements лишається питанням читабельності. Користь інтерфейсу в іншому: тип string|Stringable у сигнатурі дозволяє приймати і рядок, і value object на кшталт Email чи Money, не сподіваючись на неявне приведення при strict_types=1. Всередині все одно потрібен явний (string) $message.
ArrayAccess стоїть окремо, бо це єдиний з пʼяти інтерфейсів, який частіше шкодить. Він дає синтаксис $obj['key'], але не робить обʼєкт масивом: array_map(), array_filter(), spread ... і list() з ним не працюють. Типізація зникає (offsetGet(): mixed), опечатка в ключі не ловиться ні IDE, ні PHPStan, а isset($order['totl']) тихо повертає false замість помилки. Для контейнерів із динамічним набором ключів (ArrayObject, конфіги, обгортка над $_SESSION) він доречний. Для сутності з фіксованими полями геттери завжди виграють: вони типізовані, автодоповнюються і не приховують друкарську помилку.
declare(strict_types=1);
/**
* @implements IteratorAggregate<int, LineItem>
*/
final class Cart implements IteratorAggregate, Countable, JsonSerializable
{
/** @param list<LineItem> $items */
public function __construct(private array $items = []) {}
// один метод замість пʼяти: стан обходу тримає генератор
public function getIterator(): Generator
{
yield from $this->items; // новий генератор на кожен foreach
}
public function count(): int
{
return count($this->items); // без Countable count($cart) - TypeError
}
/** @return array{items: list<LineItem>, total: string, created_at: string} */
public function jsonSerialize(): array
{
return [
'items' => $this->items, // вкладені JsonSerializable теж обробляться
'total' => $this->total()->amount(),
'created_at' => $this->createdAt->format(DATE_ATOM),
];
}
}
$cart = new Cart([new LineItem('Книга', 2)]);
foreach ($cart as $item) { /* Traversable, а не властивості обʼєкта */ }
echo count($cart); // 1
echo json_encode($cart, JSON_THROW_ON_ERROR);
// Stringable: приймаємо і рядок, і будь-який обʼєкт з __toString()
function logLine(string|Stringable $message): void
{
error_log((string) $message); // приведення обовʼязкове
}
Проведіть межу за кількістю роботи: «`IteratorAggregate` - коли обхід можна віддати генератору або вже наявному масиву; `Iterator` - коли позиція обходу є частиною стану обʼєкта, наприклад курсор до бази або парсер потоку». І одразу додайте, що `count()` на обʼєкті без `Countable` у PHP 8 - це `TypeError`: це показує, що ви бачили міграцію з сімки.