255 — столько у нас было пар «экран × язык». Полсотни экранов, пять локалей, и каждая строчка текста лежала внутри PHP-класса. Хочешь убрать лишнюю запятую в немецком: ветка, PR, ревью, прогон тестов, деплой.

Человек, который пишет эти тексты, так не умеет. Он пишет в чат «поправьте, пожалуйста» и ждёт два дня.

Мы решили это вылечить очевидным способом: вынести экраны из кода в YAML. По оценке — пара спринтов. По факту — несколько месяцев, потому что бот живой. Вебхук молотит круглосуточно, состояние пользователей лежит в базе, а окна обслуживания у телеграм-бота не бывает в принципе. «Мы на двадцать минут прилягем» означает, что человек в середине разговора жмёт кнопку и не получает ничего.

Проект под NDA, поэтому дальше без имён: названия экранов, тексты и пути выдуманы, продуктовая специфика убрана. Проблемы настоящие, я каждую прочувствовал.

Как это выглядело раньше

Схема для ботов обычная: экран — это класс. Тексты внутри, клавиатура собирается методом.

class BalanceMenu extends Menu
{
    protected function texts(): array
    {
        return [
            'ru' => "*Баланс* ?\n\nСписаний за сегодня: :spent\nЛимит обновится через: :refillIn",
            'en' => "*Balance* ?\n\nSpent today: :spent\nLimits reset in: :refillIn",
            'es' => "...",
            'fr' => "...",
            'de' => "...",
        ];
    }

    public function replyMarkup(): InlineKeyboardMarkup
    {
        return new InlineKeyboardMarkup([
            [new InlineKeyboardButton(text: $this->buttons['topup'][$locale], callbackData: 'action:topup')],
            $this->controlButtonsRow(),
        ]);
    }
}

Пока экранов десять, это прекрасно: IDE подсказывает, PHPStan ругается, всё типизировано. На пятидесяти начинается другое.

Продуктовая гипотеза «давай переформулируем на трёх экранах» превращается в задачу разработчику. Переводчик в PHP-файл не полезет, а если полезет — сломает экранирование. И главное: карты бота не существует. Кто куда ведёт, знает только тот, кто это писал, и то примерно.

Что хотели получить: экран (текст, медиа, клавиатура) и переходы между экранами становятся данными в репозитории. Код остаётся там, где нужна логика, и больше нигде.

Почему не получилось одним коммитом

Первый план был такой: пишем новый движок, перекладываем в него все экраны, выкатываем в выходные. План прожил до первого обсуждения.

В строке чата у нас лежит указатель на текущий экран и стек навигации для кнопки «Назад». Это сотни тысяч записей в старом формате, и новый код начнёт их читать через секунду после переключения симлинка. Никакого «сначала сконвертируем, потом запустим» — конвертация горячей таблицы это блокировки на минуты.

Вторая засада приятнее по формулировке и неприятнее по последствиям: старые сообщения никуда не деваются. Кнопка, отрисованная три месяца назад, всё ещё живёт в чате. Пользователь листает историю, жмёт — и попадает в обработчик, которого в репозитории уже нет.

Плюс откат несимметричный. Код откатывается за полминуты, данные, которые новый код успел записать — нет.

Поэтому разрезали на фазы, между которыми бот продолжает работать.

Фазы миграции: что жило в коде, а что стало данными
Фазы миграции: что жило в коде, а что стало данными

Дальше — по порядку то, обо что мы ударились. Половина выглядит очевидной, если читать, а не делать.

1. \n в PHP и \n в YAML — это разные \n

Первый же автоперенос текстов поехал.

В PHP двойные кавычки разворачивают \n, и весь код был в двойных кавычках. В YAML двойные кавычки тоже разворачивают, а одинарные — нет. Скрипт переноса часть строк обернул в одинарные, потому что внутри были двойные, и в проде появились сообщения с буквальным \n\n посреди абзаца.

Лечится тем, что кавычки для многострочных текстов больше не используются вообще:

text:
  ru: |-
    *Баланс* ?

    Списаний за сегодня: :spent
    Лимит обновится через: :refillIn
  en: |-
    *Balance* ?

    Spent today: :spent
    Limits reset in: :refillIn

Про | и |- стоит знать заранее. Первый оставляет перевод строки в конце, второй срезает. В телеге этот \n глазом не виден, зато его прекрасно видят снапшот-тесты, которыми мы сверяли старый рендер с новым. Полтораста расхождений на ровном месте, пока не разобрались.

2. YAML не падает. Он молча меняет смысл

Рутину переноса делали скриптом с LLM внутри — пятьдесят классов по пять локалей руками это неделя тоски и опечаток. Текст модель переносит отлично. В отступы попадает как повезёт.

А YAML почти никогда не ругается. Сдвинул ключ на два пробела — он стал вложенным, экран потерял локаль, парсер доволен. Узнаёшь об этом от пользователя, который выбрал немецкий.

Теперь файлы экранов парсятся на CI, а не только в рантайме, и есть тест, который проверяет, что у каждого экрана все пять локалей и одинаковый набор ключей. Но самое полезное — фоллбэк, который был написан ещё до миграции:

if (!array_key_exists($locale, $texts)) {
    Log::debug('No translation for text.', [
        'locale'  => $locale,
        'chat_id' => $this->chat->id,
    ]);

    $locale = 'en';
}

Благодаря ему дырка в данных выглядит не как ошибка, а как английский текст у немецкого пользователя. Неприятно, но это не пятисотка в середине оплаты.

3. :refillIn по-испански

В текстах есть подстановки: :spent, :refillIn, дальше обычный str_replace на рендере. Пока они жили в коде, их никто не трогал.

Потом файлы открыл человек с задачей «поправь формулировки», и в испанской локали появилось :gastado. Подстановка не сработала, пользователь увидел в сообщении сырое двоеточие с английским словом. Ничего не упало. Узнали из тикета в поддержку.

Тест, который вытаскивает из каждой локали экрана токены по /:[a-zA-Z]+/ и сравнивает множества между языками, пишется за двадцать минут. Написали, конечно, после.

4. Экранирование поверх экранирования

MarkdownV2 требует экранировать почти всё: . - ! ( ) [ ] { } + = | ~ # >. Пока строка жила в коде, автор сам ставил слэши и сразу видел результат в тестовом боте.

После переезда появился новый тип текста: строка, где часть спецсимволов уже экранирована осознанно (нужна звёздочка как символ), а часть — это разметка (звёздочка как жирный). Наивный проход по таблице превращает \* в \\*, телеграм отвечает 400, пользователь видит пустоту вместо экрана.

Пришлось делать в два прохода: спрятать уже экранированные последовательности в плейсхолдеры, экранировать остальное, вернуть спрятанное на место.

private const ESCAPE_CHARACTERS = [
    '*' => '\*', '_' => '\_', '[' => '\[', ']' => '\]',
    '(' => '\(', ')' => '\)', '~' => '\~', '`' => '\`',
    '#' => '\#', '+' => '\+', '-' => '\-', '=' => '\=',
    '|' => '\|', '{' => '\{', '}' => '\}', '.' => '\.',
    '!' => '\!', '\\' => '\\\\',
];

Если коротко: как только текст стал данными, он перестал быть доверенным. Писать его будет не разработчик.

5. Клавиатура — не всегда данные

Большинство клавиатур ложатся в YAML без вопросов:

keyboard:
  allow_back: true
  allow_exit: true
  buttons:
    - - text:
          ru: "Пополнить"
          en: "Top up"
    - - text:
          ru: "История списаний"
          en: "History"

А потом попадается экран, где кнопка зависит от состояния: у человека с активной подпиской «Продлить», у остальных «Оформить». В коде это был if на две строки.

Сделали два инструмента. Правила прямо в файле:

decisions:
  check_subscription:
    rules:
      - { if: "user.is_subscribed == true", then: "balance_subscribed" }
      - { if: "default",                    then: "balance_regular" }

И люк наружу, когда нужен запрос в базу или поход в платёжку:

keyboard:
  builder: "App\\Flow\\KeyboardBuilders\\SubscriptionKeyboard"

Договорились так: если ветвление решает, что показать — это правило в YAML. Если ветвление требует данных, которых в контексте нет — это класс.

Держаться этой границы сложнее, чем кажется. «Ну давай ещё один маленький оператор в условие» — и через полгода у тебя самодельный язык программирования без отладчика, тестов и статического анализа, зато в конфиге. Поэтому в if умеет только сравнение поля контекста с константой, и расширять этот список мы отказываемся принципиально.

6. 64 байта

У инлайн-кнопки callback_data ограничено 64 байтами. Не символами. Пара UUID туда уже не влезает, а нам надо было передавать три идентификатора.

Схема стандартная: в кнопку кладём ссылку, содержимое едет в базу.

$callbackData = TelegramCallbackData::create([
    'data'    => $parameters,          // JSON-колонка
    'chat_id' => $this->id,
]);

$button = new InlineKeyboardButton(
    text: $button->text,
    callbackData: 'action:callback;id:' . $callbackData->id,
);

На нажатии — обратная операция:

case 'callback':
    $data = TelegramCallbackData::firstWhere([
        'id'      => $parameters['id'],
        'chat_id' => $this->chat->id,
    ]);

    if (!$data) {
        throw new CallbackDataNotFoundException($parameters);
    }

    $parameters = ['action' => $data->action, ...$data->data];
    break;

Дальше начинается интересное. Каждая отрисовка клавиатуры теперь пишет в базу: экран с пятью кнопками — пять INSERT. Эта таблица растёт быстрее, чем таблица сообщений, и требует индекса по (chat_id, id) и чистки по TTL, иначе однажды закончится диск.

И второе, уже про миграцию. Раз старые кнопки живут в чатах месяцами и ссылаются на имена действий в старом формате, эти имена — публичный контракт. Переименовать «заодно, раз уж всё равно переписываем» нельзя. Мы держим таблицу соответствия старых кодов новым ровно столько, сколько живёт TTL, а потом выпиливаем одним коммитом.

7. «Назад» живёт в JSON-колонке

В телеграме нет истории навигации. Её ведёшь ты сам, руками. У нас это стек в строке чата:

public function back(): ?MenuInterface
{
    $parameters = ['action' => 'home'];

    if (count($this->chat->menu_history ?? []) > 0) {
        $history = $this->chat->menu_history;

        array_pop($history);                    // текущий экран

        if (count($history) > 0) {
            $parameters = array_pop($history);  // предыдущий
        }

        $this->chat->menu_history = $history;
    }

    return $this->action($parameters);
}

Вот это и есть настоящая миграция данных в такой задаче — не таблицы, а сотни тысяч стеков с именами экранов старого формата, которые новый движок обязан понять в момент деплоя.

Конвертировать миграцией — значит на несколько минут залочить самую горячую таблицу. Почистить — значит у всех, кто прямо сейчас в диалоге, «Назад» выкинет в главное меню. Оба варианта пользователь заметит, поэтому новый движок просто умеет читать оба формата.

Расплата — слой совместимости, который очень легко забыть и оставить в коде навсегда. На него сразу заведена задача с датой удаления, привязанной к TTL из предыдущего пункта.

8. Кэш, который пережил деплой

Файлы экранов кэшируются, парсить YAML на каждый апдейт незачем:

return Cache::remember("menu:yaml:{$file}", null, fn () => Yaml::parseFile($path));

null здесь — «навсегда». Выкатили новые тексты, зашли в бота — старые. Ничего не упало, в логах чисто, в репозитории всё правильно. Полчаса мы искали ошибку в импорте, потому что версия «кэш» в голову приходит последней.

Теперь есть команда и хук, который зовёт её до прогрева конфигов:

before('artisan:optimize', artisan('menu:clear-cache'));

Общий эффект от всей затеи, кстати, вот в этом месте и проявился: деплой перестал быть деплоем кода. В пайплайне рядом стоят две строки, и вторая ничуть не безобиднее первой.

invoke('artisan:migrate');       // схема
invoke('artisan:sync:import');   // контент

9. Заодно вынесли контент из базы в git

Параллельно поехали доменные сущности — те, что раньше редактировались в админке прямо в базе.

Мотив был не только «чтобы правил не разработчик». Прод и стейдж разъезжались: кто, когда и зачем поменял сущность, знала только память коллеги. Ни истории, ни ревью, ни отката.

Импортёр устроен просто и целиком в транзакции:

public function importFromPath(string $path): array
{
    $stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'archived' => 0, 'skipped' => 0];

    DB::transaction(function () use ($path, &$stats) {
        $files = $this->getFilesToProcess($path);
        $parsedIds = $this->processFiles($files, $stats);
        $stats['archived'] += $this->archiveMissingEntities($parsedIds);
    });

    return $stats;
}

Решения, которые себя оправдали.

Первичный ключ лежит в самом файле — UUID, а не автоинкремент из базы. Иначе один и тот же файл на стейдже и на проде это две разные сущности, и всё рассыпается на первом же переносе.

Рядом с сущностью хранится md5_file(). Совпал — файл не трогаем вообще. Импорт гоняется на каждом деплое по всем файлам, и без этого фильтра каждый деплой был бы полной перезаписью контента.

Если хэш изменился, содержимое ещё сравнивается по существу. Поправил комментарий, переставил ключи местами — обновляем только хэш, новую версию не плодим:

if ($currentVersion->isIdenticalTo($newVersion)) {
    $currentVersion->file_hash = $fileHash;
    $stats['unchanged']++;

    return;
}

Изменение сущности создаёт новую версию, а не переписывает текущую. Начатые пользовательские сессии продолжают жить на той версии, с которой начинались, иначе деплой посреди дня менял бы контекст сразу у всех активных диалогов. Удалённый из репозитория файл не удаляет строку, а помечает её архивной: на неё ссылаются пользовательские данные, и DELETE тут либо упрётся во внешний ключ, либо утащит историю.

Теперь про то, что сделано плохо. Ошибка в одном файле не роняет импорт: пишем в лог, едем дальше. Для живого прода это правильно. Но счётчика у таких ошибок нет, деплой остаётся зелёным, и испорченный файл превращается в тихо пропавший контент. Ровно та же болезнь, что и с кэшем в предыдущем пункте, и лежит она в бэклоге дольше, чем я готов признать.

10. Чем проверяли, что ничего не развалилось

Двести пятьдесят пять комбинаций глазами не проверяются. Даже двадцать пять не проверяются, если честно.

Сняли снапшоты: на старом коде прогнали рендер каждого экрана в каждой локали, сохранили итоговый текст после подстановок и экранирования плюс структуру клавиатуры — сетку рядов, подписи кнопок, коды действий. Потом то же самое на новом и diff.

Расхождений было под две сотни, настоящих из них — десятка полтора. Остальное те самые хвостовые переводы строки и порядок ключей.

Чего снапшоты не видят: экран отрисован идеально, а кнопка ведёт не туда. Переходы — это граф, и его надо обходить отдельно. Простую часть закрыли тестом (из каждого экрана по каждой кнопке — существует ли экран назначения), остальное прошли руками в стейджинговом боте по списку. Скучно, зато один раз.

Где мы сейчас

Первая фаза в проде: тексты всех экранов — данные, правка формулировки на пяти языках живёт в одном файле и видна в ревью. Клавиатуры в YAML пока описаны у небольшой части экранов, остальные ждут вторую фазу вместе с переходами. Контент едет вместе с кодом и откатывается вместе с ним же.

Если бы начинали заново, поменял бы порядок действий. Снапшоты надо было снять до первой строчки нового кода, а не через две недели после, когда мы уже гадали, что именно поехало и когда. Инвалидацию кэша — в тот же PR, что и кэш. И ошибка обработки файла должна была валить деплой с первого дня, а не лежать в бэклоге полгода.

Чеклист, если собираетесь делать то же самое

  1. Снапшоты старого поведения снимаются до того, как написана первая строчка нового.

  2. Режьте на фазы. Сначала данные, потом поведение. Между фазами система работает, и это не компромисс, а требование.

  3. Многострочные тексты — только блочные скаляры. Кавычки в YAML не то, чем кажутся.

  4. Тест на полноту локалей и на совпадение плейсхолдеров между ними. Двадцать минут работы.

  5. Фоллбэк на основной язык в рантайме: дырка в данных не должна быть пятисоткой.

  6. Текст из данных недоверенный, экранирование обязано пережить уже экранированный ввод.

  7. Заранее договоритесь, что описывается декларативно, а что кодом. Иначе конфиг станет языком программирования, и очень быстро.

  8. Имена действий и экранов — публичный контракт. В чатах лежат кнопки, отправленные месяцы назад.

  9. Состояние пользователей мигрируется совместимостью, а не конвертацией. Это единственный способ обойтись без даунтайма.

  10. Слой совместимости заводится сразу с задачей на удаление и датой, иначе он ваш навсегда.

  11. Кэш данных чистится в деплое, до прогрева конфигов.

  12. Импорт контента — транзакция, идемпотентность, архивирование вместо удаления. И красный деплой, если хоть один файл не импортировался.

Если у вас была похожая миграция и набор ишью не совпал — расскажите в комментариях, на что наступали вы. Особенно интересно, как вы храните навигацию в ботах: я подозреваю, что стек в JSON-колонке не самое изящное решение, просто оно работает.

Комментарии (1)


  1. sigprof
    23.09.2026 12:49

    У инлайн-кнопки callback_data ограничено 64 байтами. Не символами. Пара UUID туда уже не влезает, а нам надо было передавать три идентификатора.

    Технически в 64 ASCII-символа при использовании base64 влезает 48 байт данных — это как раз 3 UUID; правда, больше ничего туда уже не влезет. Можно кодировать в base85 (судя по тому, что примеры такого подхода есть даже на хабре, проблем с недопустимыми символами при этом не возникает), тогда те же 48 байт ужмутся в 60 ASCII-символов, и 4 останется для чего-то ещё (например, идентификатор типа сообщения, который определяет, что там лежит дальше — кучка UUID в base85, или что-то ещё).

    Хотя при выборе формата ещё нужно учитывать, что значение callback_data может быть подставлено злоумышленником — в этом плане вариант, когда там передаётся только идентификатор записи в отдельной таблице, может быть предпочтительнее, чем непосредственная передача внутренних ID, использующихся где-то ещё. Но в любом случае надо проверять, что значение callback_data соответствует правам отправителя сообщения (поскольку в случае подделки callback_data лежащий там идентификатор мог относиться к другому пользователю).