Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Колекції

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

8 питань

Collection - обгортка над масивом із плавним, ланцюжковим API. Eloquent-запити повертають саме колекції.

$names = collect($users)
    ->filter(fn ($u) => $u->active)
    ->sortBy('name')
    ->map(fn ($u) => $u->name)
    ->values();

Десятки методів: map, filter, reduce, groupBy, pluck, each, sum. Код читається зрозуміліше за вкладені цикли й array_*-функції. Для дуже великих наборів є LazyCollection (на генераторах).

Докладніше в документації: Колекції

Усі три повертають нову колекцію (чи значення) і не змінюють вихідну.

$prices = collect([100, 250, 40]);

$withVat = $prices->map(fn (int $p) => $p * 1.2);          // [120, 300, 48]
$expensive = $prices->filter(fn (int $p) => $p > 50);      // [0 => 100, 1 => 250]
$total = $prices->reduce(fn (int $sum, int $p) => $sum + $p, 0);   // 390

$prices->all();   // [100, 250, 40] - без змін
  • map() - перетворює кожен елемент, кількість та сама;
  • filter() - залишає елементи, що пройшли умову (без замикання - прибирає «порожні»);
  • reduce() - зводить колекцію до одного значення будь-якого типу.

Пастка filter(): ключі зберігаються - вийде [0 => 100, 1 => 250], а після видалення середнього елемента було б [0 => ..., 2 => ...]. У JSON такий масив стане об'єктом. Перед відповіддю викликають values().

Мутуючі методи теж є, і їх варто знати, щоб не здивуватися: transform(), push(), put(), pop(), shift(), forget() змінюють саму колекцію.

Для простих сум є готові sum(), avg(), max() - reduce() потрібен для складнішого накопичення.

Докладніше в документації: Метод map

Model::get() повертає Illuminate\Database\Eloquent\Collection - вона успадковує звичайну колекцію й додає методи для моделей.

$users = User::where('active', true)->get();

$users->find(7);                       // модель за первинним ключем, без запиту
$users->modelKeys();                   // [1, 7, 12]
$users->load('posts');                 // жадібно завантажити зв'язок для всіх
$users->loadCount('orders');
$users->contains($someUser);           // порівнює моделі за ключем
$users->fresh();                       // перечитати з бази
$users->toQuery()->update([...]);      // один запит за ключами колекції
$users->except([1, 7]);                // без моделей з цими ключами

Що важливо розуміти:

  • методи колекції працюють у пам'яті. $users->where('role', 'admin') фільтрує вже завантажене, а User::where('role', 'admin') - робить запит. Перше доречне для невеликої вибірки, друге - коли рядків багато;
  • деякі методи (map, pluck) повертають звичайну колекцію, якщо результат уже не моделі;
  • load() на колекції - спосіб прибрати N+1, коли моделі отримали без with().

Для власних методів ($orders->totalPaid()) моделі можна дати свою колекцію атрибутом #[CollectedBy].

Докладніше в документації: Доступні методи

LazyCollection використовує PHP-генератори, щоб тримати в пам'яті лише один елемент за раз - критично для величезних наборів.

LazyCollection::make(function () {
    $handle = fopen('huge.csv', 'r');
    while (($line = fgets($handle)) !== false) {
        yield $line;
    }
})->filter(...)->take(100)->each(...);

З Eloquent:

User::cursor()->each(function ($user) { /* по одному рядку */ });

На відміну від звичайної Collection, методи не матеріалізують увесь набір - обчислення «ліниві» й виконуються лише при ітерації.

Докладніше в документації: Lazy Collections

Перетворити один зі списків на «словник» за ключем - тоді пошук стає миттєвим.

// O(n × m): для кожного замовлення шукаємо клієнта перебором
foreach ($orders as $order) {
    $customer = $customers->firstWhere('id', $order->customer_id);
}

// O(n + m): один раз будуємо індекс
$customersById = $customers->keyBy('id');

foreach ($orders as $order) {
    $customer = $customersById[$order->customer_id] ?? null;
}

На 5000 замовлень і 5000 клієнтів перший варіант - до 25 мільйонів порівнянь, другий - 10 тисяч операцій.

Інструменти:

  • keyBy('id') - один елемент на ключ;
  • groupBy('customer_id') - кілька елементів на ключ (усі замовлення клієнта);
  • mapWithKeys() - довільні пари ключ-значення;
  • pluck('name', 'id') - словник «id => назва» для випадаючих списків.

Але спершу питання: чи потрібно зіставляти в PHP? Якщо обидва списки з однієї бази, зв'язок з with() чи join зробить це ефективніше й без завантаження зайвого. keyBy() - для даних з різних джерел: API, файл імпорту, інша база.

Докладніше в документації: Метод keyBy

Скорочення для частих замикань, які лише викликають метод чи беруть поле елемента.

// звичайно
$users->each(fn (User $user) => $user->markAsVip());
$total = $orders->sum(fn (Order $order) => $order->total);
$active = $users->filter(fn (User $user) => $user->isActive());

// повідомленнями вищого порядку
$users->each->markAsVip();
$total = $orders->sum->total;
$active = $users->filter->isActive();

Працює для each, map, filter, reject, sum, avg, max, min, sortBy, groupBy, keyBy, first, contains, every, partition, unique та інших.

Коли доречно: коротка операція над кожним елементом, і читається як речення.

Коли ні:

  • потрібні аргументи чи умова - звичайне замикання зрозуміліше;
  • статичний аналіз і IDE гірше розуміють ->each->method(), тож у кодовій базі з суворим PHPStan частина команд їх уникає.

Поряд - макроси: якщо одну й ту саму операцію пишуть у багатьох місцях, її оформлюють як Collection::macro('toCsv', ...) у провайдері чи як метод власної колекції моделі.

Докладніше в документації: Повідомлення вищого порядку

Колекції зручні настільки, що ними легко почати робити роботу бази - і заплатити памʼяттю та часом.

Типова помилка:

$active = Post::all()->where('is_published', true);        // вся таблиця в памʼять
$count  = Post::all()->count();                            // теж уся таблиця
$latest = Post::all()->sortByDesc('created_at')->take(5);  // і сортування в PHP

Кожен рядок витягує всі записи, щоб потім відкинути більшість.

Те саме в базі:

$active = Post::where('is_published', true)->get();
$count  = Post::count();
$latest = Post::latest()->limit(5)->get();

Різниця не косметична: база фільтрує за індексом і повертає стільки, скільки потрібно.

Як розрізняти. Методи Builder виконуються в SQL, методи Collection - у PHP. Межа проходить там, де стоїть get(), all() чи first(): усе після них - уже PHP.

Плутанину створює однакова назва: where() є і в білдера, і в колекції. Post::where(...) фільтрує в базі, Post::all()->where(...) - у памʼяті.

Коли колекція доречна:

  • Дані вже отримані, і потрібне ще одне групування чи перетворення - другий запит дорожчий.
  • Логіка не виражається в SQL: складна умова на PHP, робота з обʼєктами-значеннями.
  • Набір свідомо малий - десятки рядків, де різниці немає.

Коли ні: фільтрація, підрахунок, сортування й обмеження на таблиці, розмір якої росте. Це робота бази.

Проміжний варіант - lazy(): обхід пачками з синтаксисом колекції, коли обробити треба всі рядки, але тримати їх у памʼяті не можна.

Докладніше в документації: Колекції

Щоб логіка над набором моделей жила в одному місці, а не повторювалася в контролерах і шаблонах.

#[CollectedBy(OrderCollection::class)]
class Order extends Model {}

class OrderCollection extends Collection
{
    public function paid(): static
    {
        return $this->filter(fn (Order $order) => $order->isPaid());
    }

    public function totalInCents(): int
    {
        return $this->sum(fn (Order $order) => $order->total_cents);
    }
}

$orders = $customer->orders;            // OrderCollection
$orders->paid()->totalInCents();

Замість атрибута можна перевизначити newCollection() у моделі.

Де межа з іншими інструментами:

  • скоп (Order::paid()) - фільтрує в базі, коли рядків багато;
  • метод колекції - коли моделі вже завантажені й потрібно обчислення в пам'яті (кошик, рахунок, сторінка зі списком);
  • окремий клас (сервіс, звіт) - коли логіка залежить від зовнішніх сервісів чи її багато.

Переваги: типи - IDE знає, що повертає $customer->orders; тести - методи колекції тестуються без бази на make()-моделях.

Обережно з назвами, що збігаються з методами базової колекції, - перевизначення filter() чи sum() зламає код, який їх очікує.

Докладніше в документації: Власні колекції