ORM и слой персистентности
ORM отображает строки таблиц на объекты PHP — и именно этим создаёт две проблемы, за которые её и спрашивают на собеседованиях. Первая: обращение к свойству объекта перестаёт отличаться от похода в базу. $post->comments выглядит как чтение поля, а на деле это SELECT, и внутри цикла по ста постам это сто SELECT. Вторая: $model->fill($data) выглядит как присваивание, а на деле это запись в таблицу тех колонок, которые прислал клиент.
Отсюда единственный тезис, который отделяет кандидата, работавшего с ORM, от кандидата, который её только вызывал: вы обязаны видеть сгенерированный SQL. Не догадываться — видеть, в логе запросов. Второй сквозной сюжет темы — выбор архитектуры персистентности: Active Record (Eloquent) сливает модель и её сохранение в один класс, Data Mapper (Doctrine) их разделяет, и этот выбор определяет, можно ли тестировать домен без базы. Разбор по слоям — ниже.
Карта темы
- Active Record — модель
Eloquent, которая и есть строка таблицы: сама себя находит, сама себя сохраняет — и тянет базу в каждый тест. - Data Mapper — сущность
Doctrineкак обычный PHP-объект,EntityManagerсpersist()/flush()и Unit of Work, который считает изменения сам. - Миграции — схема как версионированный код, таблица
migrations, честныйdown()и expand/contract для выката без простоя. - Сидеры и фабрики — фабрика как чертёж одной модели, сидер как сценарий наполнения,
make()противcreate(). - Ленивая и жадная загрузка — когда именно улетает запрос за связью, почему
with()даёт неJOIN, а второйSELECT ... IN (…), и цена жадности «на всякий случай». - Проблема N+1 — как ленивая загрузка внутри цикла превращает два запроса в 1 + N и как доказать это по логу, а не по ощущению.
- Массовое присваивание —
$fillable/$guardedкак граница безопасности, over-posting и почему тело запроса задаёт клиент, а не ваша форма. - Слой Repository — что он реально покупает, почему довод «потом заменим ORM» на
Active Recordфиктивен и когда его всё же стоит вводить.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать, что сущность Doctrine носит save() | Это Data Mapper: сущность ничего не знает о базе, сохраняет её EntityManager |
Считать Active Record-модель тестируемой без базы | Модель и есть строка: любой её тест либо идёт в базу, либо тестирует не её |
Сводить Data Mapper против Active Record к синтаксису | Различие в том, где живёт персистентность, — а от этого зависит тестируемость домена |
Думать, что with() добавляет JOIN | Жадная загрузка шлёт второй запрос WHERE … IN (…) и склеивает результат в PHP |
| Считать ленивую загрузку бесплатной, раз запроса не видно | Запрос улетает в момент первого обращения к свойству — часто внутри цикла |
Чинить N+1 одной связью, пока стреляет вложенная | with('comments') не покрывает $comment->author — нужен with('comments.author') |
Искать N+1 в логе медленных запросов | Каждый из N запросов быстрый и порога не переступает — считать надо число запросов |
Объявлять победу над N+1 по времени на dev-датасете | На ста строках N ничего не значит: меряйте счётчик запросов до и после |
| Грузить жадно все связи подряд | Вытягиваете мегабайты, которых страница не рисует, и меняете N+1 на один гигантский SELECT |
Считать $fillable деталью отображения, а не границей безопасности | create($request->all()) запишет is_admin=1, которого не было ни в одной форме |
Ставить $guarded = [], чтобы заглушить исключение | Вы открыли на запись все колонки — это и есть over-posting |
Писать down(), который не откатывает up() | Откат ломает схему сильнее, чем сломала бы неудавшаяся миграция |
| Катить схему и код одним шагом | Часть серверов за балансировщиком работает со схемой, которой ещё/уже нет |
| Наполнять продакшен справочными данными через фабрику | Фабрика генерирует случайные фейковые значения — это работа сидера |
| Заворачивать каждую модель в репозиторий-прокладку | Слой косвенности, повторяющий модель метод в метод, не даёт ничего |
Значение для собеседований
ORM — тема, на которой проверяют, читаете ли вы SQL, который написали не вы. Кандидат, который на вопрос про N+1 отвечает «база тормозит, надо индекс», выдаёт себя мгновенно: индекс не поможет, потому что каждый из тысячи запросов и так быстрый — их просто тысяча. Кандидат, который говорит «включаю DB::listen() и считаю запросы на страницу», уже решает задачу.
Что обычно проверяют:
- Чем
Active Recordотличается отData Mapperи что каждый из них делает с тестируемостью домена. - Что такое
N+1, откуда берётся1 + Nи почемуwith()— это два запроса, а неJOIN. - Как доказать
N+1в проде, а не догадаться о нём на ревью. - Почему
$fillable— вопрос безопасности, а не удобства. - Что записывает таблица
migrationsи зачем нужен рабочийdown(). - Оправдан ли репозиторий поверх
Eloquentи что именно он покупает.
Типичный неверный ответ: «with() делает JOIN, поэтому запрос один». Их два — и это важно: JOIN размножил бы родителя на каждую дочернюю строку, а WHERE post_id IN (…) вытаскивает связи одним отдельным запросом и склеивает их в PHP. Второй классический провал — «$fillable нужен, чтобы Laravel не ругался». Он нужен, чтобы клиент не дописал колонку, которой не было в форме: тело запроса пишет клиент, а не ваш HTML.