Блог, который вы читаете, я собрал за вечер: три таблицы, две модели поведения и микроразметка. Без пакетов - не из принципа, а потому что готовый блог тянет за собой категории, комментарии и админку, из которых мне нужна была пятая часть.
Ниже - устройство целиком и пять мест, где я наступил на грабли. Грабли интереснее устройства, так что если вы уже писали блоги - листайте сразу к ним.
Одна таблица для статей, две для поведения читателя
Статья - это пары полей под два языка (заголовок, анонс, текст, мета), слаг, обложка, флаг публикации, дата и мягкое удаление. Плюс два JSON-поля: теги и вопросы для FAQ.
Отдельно - таблица просмотров и таблица голосов. Обе намеренно не связаны с моделью через отношения: писать в них надо быстро и часто, а читать почти никогда.
Публикация: один скоуп на два состояния
Статья видна, если снят флажок черновика и наступила дата публикации:
public function scopePublished($query)
{
return $query->where('status', true)
->where(function ($q) {
$q->whereNull('published_at')->orWhere('published_at', '<=', now());
});
}
Отсюда бесплатно получается отложенная публикация: ставите дату в будущем, и статья появляется сама. Никакого крона для этого не нужно - условие проверяется в момент запроса. Удобно, когда пишете пачку статей за вечер, а раздавать хотите по одной в неделю.
Просмотры: денормализованный счётчик
Считать COUNT(*) по таблице просмотров на каждой отрисовке - плохая идея: список из девяти статей превращается в девять агрегирующих запросов. Поэтому счётчик лежит прямо в статье, а таблица нужна только чтобы один читатель не накручивал его перезагрузкой:
public function recordView(string $ip): void
{
$seen = DB::table('blog_post_views')
->where('post_id', $this->id)
->where('ip', $ip)
->where('created_at', '>=', now()->subDay())
->exists();
if ($seen) {
return;
}
DB::table('blog_post_views')->insert([...]);
$this->increment('views');
}
Сутки - произвольное окно, но осмысленное: вернулся дочитать вечером - плюс просмотр, обновил страницу пять раз - нет.
Голоса: один на читателя, с возможностью передумать
Под статьёй две кнопки, «да» и «нет». Голос привязан к хешу адреса, а не к самому адресу: для подсчёта достаточно отличать голосующих друг от друга, а хранить чужие IP без нужды не хочется.
DB::table('blog_post_votes')->updateOrInsert(
['post_id' => $post->id, 'ip_hash' => hash('sha256', $request->ip())],
['vote' => $vote, 'created_at' => now()]
);
Уникальный индекс по паре полей плюс updateOrInsert дают нужное поведение: повторный клик меняет прежний голос, а не добавляет второй. Защита честная, но не крепостная - смена адреса её обходит. Для «была ли статья полезна» этого достаточно, для голосования с призами - нет.
Микроразметка и порог достоверности
Страница отдаёт Article с датами публикации и изменения, а при заполненном FAQ - ещё и FAQPage. Интереснее третий блок - рейтинг:
if ($voteTotal >= 3) {
$articleLd['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => (string) round(1 + ($voteYes / $voteTotal) * 4, 1),
'ratingCount' => (string) $voteTotal,
];
}
Порог в три голоса не случаен. Один палец вверх превращается в «рейтинг 5.0 на основании одного голоса» - формально правда, по сути мусор, который поисковик вправе счесть манипуляцией. Доля голосов переводится в пятибалльную шкалу линейно: сто процентов «да» это пять, ноль - единица.
Пять граблей
Слаг должен проверяться с учётом корзины. Генератор слага ищет занятые адреса через withTrashed(). Иначе сценарий такой: удалили статью, создали новую с тем же заголовком, восстановили первую - и получили два поста с одним адресом при уникальном индексе в базе.
Порядок маршрутов важнее, чем кажется. Адреса /blog/trashed и /blog/create обязаны быть объявлены до /blog/{blog}, иначе привязка модели примет слова «trashed» и «create» за слаг и вернёт 404. Ошибка бесит тем, что выглядит как отсутствующая страница, а не как проблема маршрутизации.
Метод валидации возвращает не все поля. Классика, на которой я потерял время:
$data = $request->validate([...]);
$slug = BlogPost::makeSlug($data['slug'] ?: $data['title']); // undefined array key
Метод возвращает только те ключи, которые пришли в запросе. Необязательное поле не заполнили - ключа в массиве нет. Ловится тестом на создание статьи без слага, а не глазами.
Денормализованный счётчик стреляет в неожиданном месте. Когда я повесил на модель обработчик, который сообщает поисковикам об изменении статьи, оказалось, что increment() в Eloquent поднимает события сохранения. Каждый заход читателя стал бы отправкой в три внешних сервиса. Лечится сверкой того, что реально изменилось, но додуматься до проблемы заранее сложно - она возникает на стыке двух безобидных решений.
Сортировка по рейтингу требует нейтрального значения. В админке список сортируется по доле положительных голосов. Статьи без голосов при этом нельзя приравнивать к нулю - иначе новая статья встанет рядом с той, которую все заминусовали. Поэтому подзапрос отдаёт минус единицу:
COALESCE(SUM(vote) * 100.0 / NULLIF(COUNT(*), 0), -1)
Теги и FAQ без отдельных таблиц
И то и другое лежит в JSON-полях статьи. Отдельная таблица тегов понадобилась бы для страниц вида «все статьи по тегу», а их нет и пока не планируется.
Вводится всё текстом: теги через запятую, FAQ парами «вопрос, черта, ответ» по строке. Контроллер разбирает строки в структуру. Это оказалось быстрее любого интерфейса с кнопками «добавить строку» - и в разработке, и в наполнении.
У тегов есть необязательная английская подпись после той же черты: Локализация | i18n. Без неё на английской версии показывалась бы русская подпись - что я и обнаружил уже после публикации первых статей.
Чего в блоге нет
Комментариев - потому что модерация спама съест больше времени, чем принесут комментарии на техническом блоге. Категорий - три статьи не нуждаются в рубрикаторе. Полнотекстового поиска - по той же причине.
Всё это добавляется за вечер, когда действительно понадобится. Обратный путь - выпиливание ненужного из готового пакета - обычно дольше.