Как устроен ЦСКА.IN: большой разбор изнутри
Сайту ЦСКА.IN больше двадцати лет: он живёт с 2003 года. До сегодняшней версии он работал на CodeIgniter. Следующую версию я сделал на Yii2, но она так и не вышла: её не опубликовали. Современный сайт собран заново, на Yii3 и PHP 8.5, и основная часть его инфраструктуры вынесена в мои собственные Composer-пакеты.
Несколько цифр, чтобы было понятно, о каком масштабе речь: 4549 тестов (3193 юнит- и 1356 интеграционных), мутационный порог 95% при фактических
96% примерно из 12,5 тысяч мутантов, Lighthouse по мобильному профилю
93–98 / 100 / 100. Все цифры проверяются сборкой, а не берутся на слово.
Весь код этого сайта и пакетов я писал вместе с Claude Code, который работает прямо в терминале и в репозитории. Об этом отдельный раздел ближе к концу: как устроена такая работа, чтобы результат можно было проверять, а не просто верить.
Сайт открыт: cska.in, пакеты и профиль: github.com/rasuvaeff.
Это длинный рассказ о том, что внутри. Я постарался писать не «список модных слов», а то, что реально сделано и зачем: какие технологии работают, по каким правилам написан код, как сайт обновляется без остановки, как устроены живые обновления, как проверяется качество и почему я вынес столько кода в пакеты. В середине есть кусочки настоящего кода из проекта. В конце отдельно про пакеты, которые помогают тестировать: они мне нравятся больше всего.
Что умеет сайт
Чтобы было понятно, что вообще нужно было построить:
- Матчи трёх видов спорта (футбол, хоккей, баскетбол) и всех команд клуба: ПФК ЦСКА, ПХК ЦСКА, ПБК ЦСКА, ЖФК ЦСКА, Красная Армия, Звезда. Календарь с фильтрами, протоколы матчей, составы, события, счёт по периодам.
- Статистика: турнирные таблицы, итоги игроков и встреч с каждым соперником, судьи, бомбардиры против клуба.
- Гостевая с оценками, цитатами, фото и видео, карточками ссылок, живой лентой.
- Кумир: голосование за лучших игроков матча.
- Творчество болельщиков, сюжеты (сторис), «день в истории».
- Личные сообщения и друзья, личный кабинет с настройками (какие команды показывать, как показывать гостевую).
- Админка для редакции: справочники, матчи, протоколы, сюжеты, журнал действий.
- Мини-приложение в Telegram, анонсы в канал, личные агенты болельщиков.
Всё это должно было работать быстро, не падать во время матча, когда на сайт приходят все сразу, и не терять ни слова из того, что люди пишут.
Технологический стек
PHP 8.5 и Yii3. Yii3 это не монолитный фреймворк, а набор независимых компонентов: маршрутизатор, контейнер зависимостей, валидатор, права доступа, миграции, представления. Я использую то, что нужно, и заменяю то, что не подошло. Например, большая часть «обвязки» (надёжная доставка, повторы форм, настройки, журнал) в проекте идёт из моих пакетов, а не из Yii.
Rapira. Это сервер приложений для PHP. Код загружается один раз, воркеры живут между запросами. Воркер на каждое ядро процессора. Плюс: приложение не поднимается заново на каждый запрос. Минус: нельзя держать состояние запроса в статических полях, иначе оно утечёт в следующий запрос. Поэтому всё, что хранит состояние запроса, регистрируется в контейнере со специальным обработчиком сброса, который вызывается между запросами. Есть специальная проверка, что сброс вообще работает (тест пробует отправлять пары запросов, пока они не попадут в один и тот же воркер).
Три входа на одном коде. Сайт, админка и «дверь» для личных агентов болельщиков запускаются как три отдельных воркера на разных портах и поддоменах, но из одного образа Docker и одной кодовой базы. Админка не открывается на публичном домене, а у двери агентов нет ни cookie, ни CSRF: единственная личность это ключ в заголовке.
PostgreSQL. Основная база. Важная идея: правила данных держит сама база. Проверки (CHECK), составные ключи и ограничения не дают записать неправильную строку, даже если ошибся код. Например, счёт, статус матча, принадлежность игрока к стороне матча и вид спорта турнира проверяются ограничениями. Для каждого такого ограничения в проверочном скрипте есть «отказанная» строка, и проверяется, что база отказала именно по этому ограничению, а не по случайному другому.
Статистика это представления, а не хранимые числа. Голы, передачи, карточки, минуты игроков считаются из событий матча. Итоги встреч с соперником, таблицы сезона, рекорды команды построены представлениями поверх сыгранных матчей. Поэтому исправил счёт в матче, и все цифры по сайту сами стали верными. Это защищает от вечной проблемы статистических сайтов: «в таблице одно, в карточке игрока другое».
Redis. Кэш чтения (с группами: матчи, справочники, опросы, творчество, сюжеты сбрасываются целыми группами при изменении), ограничители, защита от повторов, подсчёты просмотров. Промах кэша строится под замком: двадцать параллельных запросов к холодной странице матча порождают одно
построение, остальные ждут результата.
Temporal. Всё, что должно выполниться надёжно и с повторами: письма, перекодирование видео, озвучка работ, карточки ссылок, подготовка карты сайта, расписания (обновление таблиц с сайтов лиг, сюжеты матча дня, закрытие голосований Кумира). Исполнитель Temporal-воркеров это RoadRunner. Видео вынесено в свою очередь и свой процесс: тяжёлая перекодировка не должна задерживать письма с кодом входа.
Centrifugo. Живые обновления: гостевая, счёт идущего матча, личные сообщения приходят без перезагрузки страницы. Подробно про них ниже: там есть интересная идея про то, что именно ходит по каналу.
Angie. Веб-сервер перед приложением (форк nginx): HTTP/3, сжатие brotli, автоматические сертификаты. Он отдаёт статику с годичным кэшем и сам сжимает ответы PHP (Rapira ответы не сжимает).
Docker Compose. И на машине разработчика, и на проде. Один образ, разные команды запуска. Локально всё поднимается одной командой.
OpenTelemetry, Jaeger, Prometheus, Grafana. Трассы запросов, метрики воркеров (сам Rapira отдаёт состояния воркеров, запросы, ошибки, очередь и память), общий дашборд. Дашборд описан скриптом, а не руками в интерфейсе: правка в панели не сохранилась бы.
Свой синтез речи и ffmpeg. Озвучка работ в «Творчестве» делается локально (Vosk), видео перекодируется нашим ffmpeg. Ничего из пользовательских материалов не уходит во внешние сервисы.
Фронтенд без тяжёлого фреймворка. Страницы рисуются на сервере. Стили и скрипты публикуются как ресурсы с хешем в адресе (изменился файл, изменился адрес, можно кэшировать навсегда). Никакого inline-кода в разметке, шрифты свои, без CDN. Скрипты с отложенной загрузкой, лёгкие. По мобильному профилю Lighthouse у сайта производительность 93–98, доступность 100, SEO 100.
Принципы кода
Это то, ради чего я переписывал сайт: код должен быть таким, чтобы его можно было менять без страха.
Слои и границы
Код разложен на слои: предметная область, прикладной слой, инфраструктура, а сверху входы (HTTP, админка, консоль, агенты). Каждая часть сайта (матчи, гостевая, Кумир, аккаунты, статистика) это отдельный вертикальный срез. Границы между слоями и срезами проверяет deptrac: если разработчик подключил в прикладном слое класс инфраструктуры, сборка не пройдёт. Это не «договорённость в README», это падающий тест.
Есть и более тонкие правила, которых deptrac не видит, и они оформлены обычными тестами архитектуры. Например:
- у каждого обработчика ровно один публичный метод
handle(); - команды и запросы это значения, без поведения;
- никто не берёт контейнер зависимостей в руки и не ходит за сервисами сам;
- время нигде не берётся напрямую, только через интерфейс часов;
- в приложении нет сырого SQL.
Один обработчик на операцию и ожидаемые отказы как значения
Вот настоящий обработчик из проекта: автор удаляет своё сообщение в гостевой.final readonly class DeleteMessageHandler
{
/** @return Result<null, DeleteRefusal> */
public function handle(DeleteMessageCommand $command): Result
{
$message = $this->messages->find($command->messageId);
$denied = $message instanceof GuestMessage
? $message->denyChange($command->userId, $this->clock->now(), sanctioned: ...)
: ChangeDenied::NotFound;
if ($denied instanceof ChangeDenied) {
return Result::err(match ($denied) {
ChangeDenied::NotFound => DeleteRefusal::NotFound,
ChangeDenied::NotYours => DeleteRefusal::NotYours,
ChangeDenied::Sanctioned => DeleteRefusal::Sanctioned,
ChangeDenied::TooLate => DeleteRefusal::TooLate,
ChangeDenied::HasAnswers => DeleteRefusal::HasAnswers,
});
}
// ... удаляем и сообщаем событием
return Result::ok(value: null);
}
}
- Правило «свое сообщение можно удалить первые пять минут и пока на него не ответили» живёт в предметной области (
denyChange), а не размазано по контроллерам. - Отказ это значение с конкретной причиной (
TooLate,HasAnswers), а не исключение и не безликоеfalse. Страница по причине говорит человеку, что именно пошло не так, а тест проверяет каждую причину отдельно. - Время приходит из часов. Тест ставит часы на нужный момент и проверяет границу в пять минут до секунды.
Условия запросов как значения (спецификации)
В коде приложения нет сырого SQL. Условия собираются из спецификаций. Одна и та же спецификация применяется и к запросу в базе, и к объекту в памяти. Так устроен календарь: правило «какие матчи показать» написано один раз, а специальная проверка на реальных данных убеждается, что SQL и память выдают одно и то же. Пример (упрощённо), как формируется «ближайшие матчи выбранных команд»:
CalendarSpecifications::upcoming(
sportId: null,
now: $now,
limit: 5,
teams: [2 => [1, 314, 409]], // хоккей: ЦСКА, Красная Армия, Звезда
);Из этого вызова получается условие: не сыгран, не идёт, начало не раньше сейчас, отсортировано по времени, не больше пяти. Менять правило календаря можно в одном месте.
События после транзакции
Обработчик сообщает, что случилось (матч изменён, сообщение появилось, голосование закрыто), только после того, как транзакция зафиксирована. Слушатели сбрасывают кэш, публикуют подсказку в канал, ставят письмо в очередь. Если слушатель упал, это уходит в журнал, но не откатывает запись пользователя.
Без подавлений и с понятными правилами
Psalm работает на самом строгом, первом уровне. Подавлять предупреждения (@psalm-suppress) нельзя ни в коде, ни через базовый файл: причину нужно исправлять. Перед закрытием любой задачи запускается полный набор проверок: стиль, Psalm, deptrac, Rector, проверка ссылок на классы и зависимостей. Тестов нет без #[Covers]: так инструмент мутационного тестирования знает, какие тесты отвечают за какой код.
Надёжность: чтобы ничего не терялось и не дублировалось
Outbox: дело записано в той же транзакции, что и данные
Типичная ошибка: сохранить письмо в базу, а потом отдельно отправить его в очередь. Если между этими шагами что-то упало, письмо потеряно или улетело без записи. Я использую шаблон outbox. Вот как отправляется письмо:
$job = $this->transactions->run(function () use ($to, $letter): string {
$id = $this->outbox->add($to, $letter->subject, $text, $html, $this->clock->now());
return $this->jobs->enqueue(JobType::MailDeliver, (string) $id, ['mail' => $id]);
});
$this->jobs->deliver($job); // сразу пробуем передать Temporal
Письмо и дело на его отправку записываются вместе или не записываются вовсе. После фиксации приложение сразу пробует запустить воркфлоу. Если Temporal недоступен, дело остаётся в таблице, и фоновый демон подберёт его через секунду. Идентификатор воркфлоу детерминирован (mail.deliver:42), поэтому повторный запуск того же дела безвреден. Текст письма (в нём бывают коды входа) стирается из базы, как только письмо ушло. Так же устроены карточки ссылок, перекодирование видео, уведомления в Telegram.
Идемпотентность форм
Формы админки и выпуск приглашений в кабинете несут скрытый одноразовый ключ. Двойной клик по кнопке или повторная отправка той же страницы не создадут вторую запись и не отправят второе письмо. При приглашении по почте без этого двойной клик слал бы два письма. Ответ на повтор берётся из Redis (пятнадцать минут), а отказ формы (ошибка проверки) ключ не занимает, чтобы человек мог исправить и отправить снова.
Ограничители и размыкатель цепи
- Не больше трёх одновременных запусков ffmpeg на построение миниатюр (волну загрузок картинок). Ограничение общее для всех воркеров, оно хранится в Redis. Перекодировка видео — отдельная история: она живёт
в своей очереди и своём процессе и письма с кодом входа не задерживает.
- Вызовы языковой модели идут через размыкатель цепи: после серии сбоев (сеть, ключ, баланс, 5xx) вызовы сразу отказывают на пару минут, а не копятся и не тормозят страницы. Каждый вызов модели ещё и записывается в журнал: промпт, ответ, токены, задержка.
Выкладка без остановки сайта
Раньше выкладка обновлением всех копий разом показывала посетителю заглушку. Теперь обновление делает один скрипт, и порядок такой:
- Подтянуть код и собрать новый образ (старые копии продолжают работать на старом).
- Применить миграции одним шагом в отдельном контейнере: две новые копии не должны гнать их наперегонки.
- Для сайта, админки и двери агентов по очереди: поднять новые копии рядом со старыми и ждать, пока проверка здоровья даст «готово».
- Дать Angie заметить новые адреса (пауза в несколько секунд).
- Остановить старые копии, оставив тридцать секунд на запросы, которые уже идут.
- Обновить воркеры Temporal и зарегистрировать расписания заново (повтор безопасен).
- Мягко перезагрузить конфигурацию Angie и проверить главную страницу.
Если новая копия не стала готова за отведённое время, она удаляется, а сайт продолжает работать на прежней версии, и выкладка завершается с ошибкой. Из этой схемы следует правило для миграций: они обязаны работать и со старым кодом, пока идёт замена. Сначала добавить колонку, использовать её в следующем релизе, удалять и переименовывать только релизом позже. Всё, что несовместимо, выкладывается отдельно, под заглушкой.
Одна запись в гостевой: что происходит за кулисами
Хороший способ увидеть, как всё соединяется: проследить за одним сообщением. Для масштаба: гостевой двадцать лет, в нём 479 тысяч сообщений и двадцать две тысячи страниц; поиск по слову в этой куче держит индекс pg_trgm — 35 миллисекунд вместо 283 без него.
- Человек пишет сообщение со ссылкой на статью. Форма с одноразовым ключом, поэтому двойной клик ничего не дублирует.
- Обработчик проверяет правила (красная карточка, пауза между сообщениями, повтор, длина) и записывает сообщение. Текст разбирается собственной разметкой: допустим только разрешённый набор тегов, всё остальное экранируется.
- После фиксации транзакции обработчик отправляет событие «сообщение появилось».
- Слушатели: сбрасывают кэш этого сообщения, публикуют подсказку в Centrifugo, сообщают агентам, а также запрашивают карточки ссылок.
- Подсказка, а не текст. В канал уходит только
{тип: сообщение, номер: 123}. Открытые страницы после случайной паузы (от трёх десятых до трёх секунд, подсказки пачкой, не больше десяти сообщений за раз) сами запрашивают это сообщение у сервера. Так сервер отдаёт каждому читателю ровно то, что ему разрешено видеть. Текст, скрытый красной карточкой, не покидает сервер для того, кому его нельзя читать. А случайная пауза нужна, чтобы после гола тысячи страниц не пришли за обновлением в одну и ту же секунду. - Параллельно ссылка попадает в outbox, затем в воркфлоу Temporal, который идёт на страницу по ссылке, достаёт заголовок, описание и картинку и сохраняет карточку.
- Когда карточка готова, сообщению рассылается ещё одно событие «изменилось». Открытые страницы запрашивают сообщение заново, и карточка появляется на месте без перезагрузки.
Это одна и та же схема для гостевой, комментариев, личных сообщений, счёта матча и сюжетов: отдельное событие, подсказка, запрос за свежим.
Как карточка ссылки защищена от подвоха
Адрес для карточки вводит посторонний человек, значит, он может попытаться заставить сервер сходить во внутреннюю сеть. Поэтому:
- разрешены только http и https, только порты 80 и 443;
- допускаются только публичные адреса (внутренние сети, loopback и адрес метаданных облака отвергаются);
- имя хоста разрешается самим клиентом, каждый адрес проверяется той же политикой, и соединение закрепляется за проверенным адресом, чтобы подмена DNS ничего не дала;
- каждое перенаправление проверяется заново, их не больше трёх;
- размер ответа и время ограничены, принимаются только страницы (HTML);
- картинка скачивается к нам, оптимизируется и отдаётся с нашего адреса: читатель на чужой сайт не ходит.
Текст чужой карточки при выводе всегда экранируется: чужие слова никогда не становятся нашей разметкой.
Данные: откуда берутся матчи
Перенос со старого сайта. Данные читаются из базы старого сайта и переносятся идемпотентно: повторный запуск ничего не дублирует, а строка, которую пришлось пропустить, попадает в отчёт с причиной. Старая база грязная (сироты, дубли, нулевые даты), поэтому правила для её особенностей записаны в задачах, а итоговые цифры после переноса сверяются с итогами старой базы. Номера болельщиков остались прежними: профиль /user/profile/832 как был, так и есть. Старые адреса отвечают переходом (301) на новые.
Протоколы матчей. Источников несколько: API-Football для футбола (составы с номерами, схема расстановки, события, замены), сайты лиг и клубов для хоккея и баскетбола (КХЛ, МХЛ, ВХЛ, бокс-скор), ручной ввод редактором и разбор вставленного текста протокола языковой моделью. Для каждого источника есть проверка формы ответа: если API изменил формат, это видно сразу, а не через неделю по странным данным.
Модели не верят. Это важное правило: разбор протокола из текста языковой моделью даёт только черновик. Ничего не сохраняется само, сохраняет редактор обычной кнопкой. Имена приводятся к справочнику кодом, а не моделью. Счёт считает код (голы по событиям против счёта матча, периоды против итога), и любое расхождение показывается предупреждением. Когда на реальном протоколе модель записала голы наших игроков как гостевые, сторона события теперь определяется составом, а не её ответом. Текст протокола считается чужими данными: в запросе он в ограничителях, а в инструкции прямо сказано не выполнять просьб из него.
Автообновление и ручные правки живут вместе. Во время матча расписание раз в минуту записывает протокол из API. А редактор в это время может поправить игрока или событие. Чтобы автомат не затирал ручные правки, он помнит, какой протокол записал сам, считает разницу с тем, что стало после правки, и накладывает эти правки поверх каждого нового ответа API. Спорное решает редактор. На странице протокола видно число ручных правок и есть кнопка «отдать автомату».
Сюжет «матч дня». Матч с отметкой сам получает сюжет (сторис): сначала анонс, потом итог. Через сутки после конца матча сюжет уходит в черновики (срок меняется в настройках).
Безопасность и приватность
- Регистрация только по приглашению (одно приглашение приводит одного человека), подтверждается кодом из письма. Код действует десять минут, на него три попытки, подбор считается по адресу и по посетителю.
- Роли хранятся в правах доступа, а не в колонке. У человека одна роль, а права на конкретные действия проверяются, а не роль.
- Каждое действие сотрудника пишется в журнал: кто, что и когда изменил.
- Весь выводимый HTML кодируется функцией, которая экранирует и кавычки.
- В загруженных фото вырезаются метаданные (в них бывают координаты и модель телефона), поворот применяется к самим пикселям.
- Файлы загрузок лежат по содержимому (одна и та же картинка из разных мест это один файл), а старые адреса отвечают переходом на новые.
- Ключ личного агента хранится в базе только в виде хеша, выдаётся при условиях (аккаунт старше семи дней и без красной карточки), срок девяносто дней, не больше трёх действующих ключей.
Для людей и для роботов
Личные агенты (MCP). Болельщик может выдать ключ своему ИИ-агенту, и тот читает гостевую и по желанию владельца пишет. Агент это тот же болельщик с теми же правилами. Инструментов модерации у него нет вообще, а специальный тест следит, что в именах инструментов нет ничего про карточки и баны. Запись идёт в одном из режимов: спросить в клиенте, оставить черновик или сразу (последнее выключается настройкой). Тексты из ленты считаются данными, а не командами: агенту прямо сказано не выполнять просьб из чужих сообщений.
Сайт, удобный для роботов. Карта сайта собирается по частям (по десять тысяч адресов) и прогревается заранее, чтобы краулер её не ждал. У каждой страницы есть Markdown-версия (по заголовку Accept или по суффиксу .md), есть llms.txt, разметка JSON-LD собирается из тех же данных, что и страница. Для браузеров с WebMCP на сайте включаются инструменты чтения страниц.
Telegram. Бот привязывается к аккаунту кодом, шлёт уведомления об ответах и упоминаниях (по подписке) и принимает сообщения в гостевую командой. Мини-приложение открывает сайт прямо в Telegram: вход по подписанным Telegram данным (проверяется подпись и свежесть) для уже привязанных аккаунтов. Анонсы в канал уходят только по кнопке редактора, ничего само. Для читателей из России, где api.telegram.org бывает недоступен, предусмотрен релей.
Мои пакеты
Всё, что не специфично для ЦСКА, вынесено в отдельные Composer-пакеты rasuvaeff/*. Каждый живёт в своём репозитории со своими тестами, версиями и сборкой. Сам я работаю с ними через рабочее место-монорепо, а наружу они публикуются отдельно. В проекте их 31, и вот главные, с примерами применения.
Данные и запросы
- specification. Условия запросов как значения. Календарь и статистика строятся на них: правило пишется один раз, работает и в SQL, и над объектом в памяти.
- result. Тип «успех или причина отказа». Обработчики записи возвращают его вместо исключений для ожидаемых ситуаций.
- schema. Проверка формы входящих данных: ответы внешних API (если формат изменился, сообщение называет путь к поломанному полю), ответ языковой модели, подписанные данные Telegram.
- duration. Понятные интервалы вместо голых чисел: пауза размыкателя цепи у языковой модели записана как
cooldown: Duration::seconds(120), а не просто120, и никто не гадает, секунды это или миллисекунды.
Надёжность
- yii3-outbox и yii3-outbox-db. Доставка важных дел без потерь (разобрана выше).
- yii3-idempotency (хранилище в Redis). Повторная отправка формы не создаёт дубль.
- circuit-breaker. Размыкатель цепи вокруг языковой модели.
- bulkhead. Ограничитель одновременных вызовов (ffmpeg для уменьшенных картинок).
- yii3-health-check и yii3-maintenance-mode. Проверки здоровья для выкладки и страница-заглушка на время работ.
Настройки и контроль
- yii3-settings и yii3-settings-db. Настройки, которые редактор меняет в админке без выкладки кода: пауза между сообщениями, размер страницы, лимиты, срок жизни сюжета матча дня. Для каждой настройки описаны тип, значение по умолчанию и допустимый диапазон.
- yii3-feature-flags и yii3-feature-flags-db. Флаги для сотрудников: включить возможность выборочно, не выкладывая код.
- yii3-audit-log и yii3-audit-log-db. Журнал «кто что менял». Секреты в нём маскируются.
- yii3-api-problem и yii3-correlation-id. Единый формат ошибок и сквозной номер запроса в журналах.
Медиа и внешний мир
- link-preview и pinned-http-client. Карточки ссылок (адаптеры для YouTube, VK, Telegram, Rutube и других, плюс универсальный разбор страниц) с описанной выше защитой от подмены адресов.
- media-converter. ffmpeg из PHP: операции описываются значениями (перекодировать, обрезать, вырезать кадр, подготовить для потоковой выдачи), команда формируется строго, с повторами и ограничениями по времени.
- yii3-centrifugo. Живые обновления.
- yii3-seo. Метаданные страниц, карта сайта,
robots.txt. - yii3-mcp. Дверь для личных агентов и журнал их вызовов (
yii3-mcp-audit-log-bridge). - yii3-telemetry и yii3-telemetry-otel. Трассы запросов.
- context и context-http. Срок и отмена запроса, которые можно прокинуть через цепочку вызовов (пока подключены в служебном маршруте проверки).
Что даёт такой подход
- Улучшение или исправление делается один раз и попадает во все проекты, где пакет стоит.
- У каждого пакета свои тесты, свои версии и автоматическая проверка обратной совместимости: ломающее изменение не проскочит в минорной версии.
- Сам сайт остаётся тонким слоем: предметная логика ЦСКА и склейка. О том, как устроены outbox или идемпотентность, думать в проекте не нужно: это решено пакетом.
- Пакет читается и проверяется отдельно от всего сайта, поэтому границы понятны и новому человеку.
Цена тоже есть: пакеты нужно поддерживать, релизить и следить за совместимостью. Я принял эту цену осознанно: она окупается там, где один и тот же механизм нужен в нескольких проектах.
Пакеты, которые помогают тестировать
Отдельно про четыре пакета из require-dev. Они делают тесты сильнее, чем «написать проверки руками». Рядом с ними работает фреймворк тестов Testo и Infection для мутаций (они не мои).
property-testing: тесты на свойства
Обычный тест проверяет несколько примеров, которые придумал человек. Тест на свойства формулирует правило, которое должно выполняться всегда, а генератор придумывает тысячи входов, находит контрпример и сжимает его до самого короткого. Вот настоящий тест из проекта про определение адреса посетителя за прокси:
/** слева в заголовке пишет посетитель: он не может перебить
адрес, добавленный нашим прокси справа */
#[Property(runs: 300)]
public function whatTheVisitorWroteOnTheLeftNeverWins(string $forged, int $last): void
{
$real = '203.0.113.' . $last;
Assert::same(
ClientIp::resolve(peer: '10.0.0.2', forwarded: $forged . ', ' . $real, proxies: '10.0.0.0/8'),
$real,
);
}
Что бы посетитель ни подставил в заголовок (триста случайных строк за прогон), настоящий адрес выиграет. А вот тест на разметку гостевой: что бы ни ввёл автор, в итоговой странице допустимы только наши собственные теги:
#[Property(runs: 800)]
public function onlyOurOwnMarkupSurvives(string $source): void
{
$html = GuestText::html(source: $source, mentions: ['kran' => 832]);
// ... все теги в $html должны подходить под список разрешённых шаблонов
}
- Ссылка в гостевой могла вставить в страницу чужой обработчик события (
onmouseover): при выводе не экранировались кавычки. Теперь выводится функцией, которая экранирует и кавычки, и отдельный тест архитектуры запрещает старую. - Транслитерация имён неоднозначна: из одного набора букв («zh», «ia») получаются разные слова, и это нельзя было исправить «улучшением». Находка осталась в тесте как пример.
- Языковая модель иногда отвечает пустым объектом
{}. Такой ответ должен отвергаться, а не превращаться в пустой протокол.
Ещё две важные черты. Найденный контрпример запоминается и проверяется первым при следующих запусках, поэтому регрессия не теряется: даже если «повезло» в тот раз. А для ветвящихся свойств можно потребовать, чтобы каждая ветвь действительно достигалась хотя бы в нескольких процентах прогонов.
understudy: дублёры без магии
Классические мок-фреймворки часто превращают тест в пересказ реализации. Understudy устроен проще: вызов задаётся замыканием, у дублёра нет ничего сверх контракта.
$search = Understudy::for(NicknameSearch::class);
expect(fn () => $search->startingWith('KR', 8))
->returns([new NicknameHint(id: 1, nickname: 'KR1')]);
// ... вызываем код, который должен спросить поиск ников
Understudy::nothingElse($search); // и больше ничего не просили
Сначала описываешь ожидаемый вызов с аргументами, потом проверяешь, что он был и ничего лишнего. Дублируем только внешние границы: сеть, почту, Temporal, Centrifugo. Базу данных в тестах не подменяем никогда. Тесты репозиториев и всего приложения идут на настоящем PostgreSQL (отдельная база, имя которой обязано заканчиваться на _test, поэтому тест физически не может добраться до рабочей базы), и каждый тест выполняется в транзакции, которая откатывается. Приложение для интеграционных тестов запускается целиком в одном процессе: настоящий PSR-запрос на входе, настоящие ответы на выходе.
mutation-history: чтобы качество тестов не падало тихо
Мутационное тестирование намеренно ломает код (меняет условие, убирает вызов, подменяет константу) и смотрит, заметит ли это хоть один тест. Доля пойманных поломок называется MSI. Если тест не заметил, что условие перевернули, значит, он проверяет не то, что нужно. В проекте порог 95%, на последнем прогоне 96% примерно из 12,5 тысяч мутантов (Domain и Application). Пакет mutation-history хранит историю прогонов, и каждый следующий сверяется с предыдущим: новый прогон не может быть хуже по тем же местам. Качество не может тихо упасть: новый класс без теста немедленно снижает показатель.
Из практики: Infection не засчитывает тесты с источниками данных (#[DataProvider]), поэтому для мутационных тестов пишутся обычные методы. А мутант, который ничего не меняет в наблюдаемом поведении, остаётся в отчёте как есть, а не заглушается в настройках.
rector-named-literals
Автоматически расставляет имена аргументов (format(format: 'H:i')). Мелочь, но весь код читается без заглядывания в сигнатуры, а правило не требует дисциплины от человека: Rector сам предложит исправление, а сборка не пройдёт, пока оно не применено.
Итого по тестам
- 3193 юнит-теста и 1356 интеграционных, всего 4549.
- Тесты на свойства для правил с большим входом: турнирная таблица Кумира, адрес посетителя за прокси, пути загрузок, разметка гостевой, суточные потолки входа и многое другое.
- Дымовые сценарии: поднимают весь стек и проверяют сайт целиком (живые обновления, письма из воркера, карточки ссылок на поддельном «чужом сайте», выкладка).
- Отдельный нагрузочный стенд — и он уже окупился: однажды он показал, что планирование запроса календаря из десяти соединений стоило дороже самого выполнения (20–50 мс против меньше миллисекунды). Фикс порядка
соединений на уровне базы опустил p95 страниц с 234 до 104 мс и вдвое срезал CPU базы. - Строчное покрытие — 80% (без миграций и импорта, у которых свои проверки против настоящей базы, — 88%). Мы не гонимся за цифрой 100%: строчное покрытие легко обмануть, поэтому порог в проекте мутационный — он проверяет, что тесты вообще замечают поломки.
Как это писалось: вместе с Claude Code
Коротко: я принимаю решения, Claude Code пишет код, тесты, миграции и документацию, а правдивость результата держат проверки, а не уверенность ответа.
Кто за что отвечает. Что строим, какие правила действуют, в каком порядке, как должно выглядеть, что выкладываем и когда: это решаю я. Claude Code берёт на себя ремесло: пишет обработчики, тесты, миграции, правит вёрстку, разбирает падения, ведёт документацию, делает ревью. Любое решение, которое меняет поведение сайта для людей, я принимаю сам. Выкладка и публикация только по моей явной команде.
Правила лежат в репозитории, а не в моей голове. В проекте и в каждом пакете есть файл AGENTS.md: что это за код, какие команды запускать, какие ловушки уже известны. Туда попадают «золотые правила». Например: нельзя сказать «готово» без свежего зелёного прогона проверок, нельзя подавлять предупреждения анализатора, любое изменение интерфейса обновляет документацию и примеры. Когда агент или я наступаем на грабли, правило дописывается вместе с датой и описанием случая. Так ошибка перестаёт быть «знанием одного разговора» и становится правилом проекта.
Навыки. В репозитории лежат наборы инструкций под конкретные области: архитектура и слои, правила базы данных, гостевая, работа с API-Football, тестирование, проверка интерфейса в настоящем браузере. Агент загружает нужный, когда берётся за такую задачу. Мои пакеты поставляются вместе со своими навыками и файлом llms.txt (краткий справочник API для ИИ), чтобы помощник использовал пакет правильно, а не угадывал.
Проверки как поручитель. ИИ ошибается, и это нормально. Поэтому доверие даёт не то, насколько уверенно звучит ответ, а то, что зелёное в проверках: юнит- и интеграционные тесты, тесты на свойства, Psalm первого уровня, deptrac, Rector, мутационное тестирование. Если агент что-то сломал, сборка скажет об этом раньше, чем человек. Интерфейс агент проверяет в настоящем браузере: открывает страницу, кликает, делает снимок экрана и смотрит, нет ли ошибок в консоли, при необходимости заводит временный аккаунт и убирает за собой.
Ограждения. Опасные команды (например, жёсткий сброс изменений в git) заблокированы хуком, и без моей отмашки не выполнятся. Секреты и ключи, которые мелькнули в разговоре, не сохраняются ни в файлах, ни в памяти. Выкладка на прод и публикация пакетов идут только по явной команде, а если что-то выглядит рискованным, агент сначала называет риск, а потом спрашивает.
Что получается на практике. Скорость ушла в творчество и качество: рутина (перенос данных, тесты на каждую ветку, двуязычная документация пакетов, ревью каждого изменения) больше не стоит часов. Но цена другая: нужно уметь формулировать правила, быстро читать чужой код и не принимать зелёное за правильное, пока не понятно, что именно проверяется. Поэтому половина этого проекта это не код, а правила и проверки вокруг него.
Чего пока нет
Чтобы не создавать иллюзию идеальности. Не доделано: плееры некоторых соцсетей в карточках ссылок, запись через WebMCP (в спецификации пока нет подтверждения действия), собственные метрики приложения по маршрутам (трассы есть, метрик нет), карточки для самых старых сообщений гостевой, которые разбираются постепенно.
Итог
Сайт, которому больше двадцати лет и у которого уже была не вышедшая версия на Yii2, собран заново вместе с Claude Code так, чтобы его можно было дальше развивать без страха что-то сломать. Слои, проверяемые автоматически. База, которая не даёт записать плохую строку. Выкладки без остановки. Надёжная доставка и защита от повторов, вынесенные в пакеты. И много тестов, из которых часть проверяет сами тесты.
Если интересно посмотреть, как это работает вживую, сайт открыт: cska.in