Приём платежей в Laravel: чек-лист перед продакшеном
Что проверить в интеграции Laravel с платёжным шлюзом до того, как включить приём реальных денег: вебхуки, идемпотентность, статусы заказов и логи.

Платёжная интеграция ломается не на этапе «списать деньги», а на возврате пользователя из платёжки и на вебхуке от банка. Пользователь оплатил, закрыл вкладку, вебхук пришёл дважды — и заказ либо оплачен два раза, либо не оплачен вовсе. Ниже — чек-лист для Laravel 10.x и 11.x: что проверить в коде и инфраструктуре, прежде чем переключить магазин с тестового ключа на боевой. Формат — практика: разбираем конкретные точки, где очевидное решение даёт проблему.
Речь о самописной интеграции через HTTP API шлюза (ЮKassa, Stripe, CloudPayments, Тинькофф и подобные), а не о готовом кассовом решении «под ключ». Если вы используете официальный пакет вендора — часть пунктов он закрывает за вас, но проверить их всё равно стоит.
Модель платежа отдельно от заказа
Первая ошибка новичка — хранить статус оплаты прямо в таблице заказов полем is_paid. Как только появляются повторные попытки оплаты, частичные возвраты и разные способы, одного булева поля не хватает.
Заведите отдельную таблицу платежей. У одного заказа может быть несколько платежей: первая попытка отклонена банком, вторая прошла. Заказ — это намерение купить, платёж — конкретная транзакция с внешним идентификатором от шлюза.
- order_id — к какому заказу относится;
- gateway_payment_id — идентификатор транзакции на стороне шлюза, по нему ищем платёж в вебхуке;
- status — enum:
pending,succeeded,canceled,refunded; - amount и currency — сумма и валюта на момент создания платежа;
- idempotency_key — ключ, с которым платёж отправлен шлюзу.
Деньги — это не поле в заказе. Это отдельная сущность со своей историей состояний, и её нельзя перезаписывать: только добавлять новые записи и менять статус существующей по правилам перехода.
Вебхук — источник правды, а не redirect
Самая частая архитектурная ошибка: считать заказ оплаченным в тот момент, когда шлюз вернул пользователя на return_url. Redirect ненадёжен. Пользователь может закрыть вкладку до возврата, потерять связь, нажать «назад». А ещё return_url легко подделать руками в адресной строке.
Единственный надёжный сигнал об оплате — серверный вебхук (callback, notification) от шлюза на ваш эндпоинт. Return_url используйте только чтобы показать пользователю страницу «спасибо, проверяем оплату», а финальный статус выставляйте по вебхуку.
Что проверить в обработчике вебхука
- Проверка подписи. У каждого шлюза свой механизм: HMAC-подпись в заголовке, IP-фильтр отправителя, отдельный секрет. Без проверки подписи любой может отправить вам поддельный «платёж прошёл». Точный алгоритм подписи смотрите в документации вашего шлюза — не изобретайте свой.
- Эндпоинт вне CSRF. Внешний сервис не пришлёт CSRF-токен Laravel. Добавьте путь вебхука в исключения проверки CSRF, иначе получите 419 и шлюз будет считать доставку неудачной. В Laravel 11 это настраивается в
bootstrap/app.phpчерезvalidateCsrfTokens(except: [...]), в Laravel 10 — в свойстве$exceptу middleware VerifyCsrfToken. - Быстрый ответ 200. Шлюз ждёт ответа несколько секунд, иначе считает доставку проваленной и шлёт вебхук снова. Тяжёлую работу (отправка чека, письма) выносите в очередь, а в ответ отдавайте 200 сразу после сохранения факта платежа.
Идемпотентность: вебхук придёт дважды
Повторная доставка вебхука — норма, а не сбой. При таймауте, ретрае или ручной переотправке из личного кабинета шлюза один и тот же succeeded прилетит вам два, три, пять раз. Если каждый вызов начисляет бонусы или отгружает товар — вы отгрузите его несколько раз.
Защита — обработка по внешнему идентификатору транзакции с блокировкой строки. Схема на псевдокоде близка к рабочей:
// Обработчик вебхука, Laravel 11.x
public function handle(Request $request)
{
// 1. Проверили подпись до всего остального (код зависит от шлюза)
$gatewayId = $request->input('object.id');
DB::transaction(function () use ($request, $gatewayId) {
// Блокируем строку платежа, чтобы параллельный вебхук ждал
$payment = Payment::where('gateway_payment_id', $gatewayId)
->lockForUpdate()
->firstOrFail();
// Уже в финальном статусе — второй вебхук игнорируем
if ($payment->status === 'succeeded') {
return;
}
$payment->update(['status' => 'succeeded']);
// Бизнес-логику откладываем в очередь, а не выполняем тут
FulfillOrder::dispatch($payment->order_id);
});
return response()->noContent(); // 204/200 шлюзу
}Ключевые детали: lockForUpdate() внутри транзакции не даёт двум параллельным вебхукам одновременно пройти проверку статуса. Без блокировки два запроса прочитают pending, оба перейдут к отгрузке — и вы получите двойное начисление, которое в тестах не воспроизводится, а на боевом всплывает в час пик.
Ключ идемпотентности на исходящих запросах
Идемпотентность нужна не только на входящих вебхуках. Когда вы сами создаёте платёж в шлюзе, между вашим запросом и ответом может оборваться связь: деньги списаны, а вы об этом не узнали и создаёте платёж повторно. Большинство API принимают заголовок Idempotence-Key (у Stripe — Idempotency-Key): при повторе с тем же ключом шлюз вернёт первый результат, а не создаст вторую транзакцию. Генерируйте ключ один раз на попытку и сохраняйте его в записи платежа.
Сумму и валюту берём с сервера
Никогда не доверяйте сумме, пришедшей с фронтенда. Классическая уязвимость: цена товара уходит в скрытом поле формы, пользователь меняет её в DevTools и платит рубль вместо тысячи. Сумму к оплате всегда пересчитывайте на сервере из корзины и товаров в базе непосредственно перед созданием платежа.
При получении вебхука сверьте сумму и валюту в ответе шлюза с тем, что вы сохранили в записи платежа. Если они не совпадают — не выставляйте succeeded, а логируйте расхождение и разбирайтесь вручную.
Деньги хранятся в минимальных единицах
Float для денег — источник ошибок округления. 0.1 + 0.2 в PHP не равно 0.3. Храните суммы в копейках целым числом (integer) либо в decimal с фиксированной точностью. Большинство шлюзов принимают и возвращают суммы в минимальных единицах валюты — копейках, центах. Сравнивая сумму вебхука с сохранённой, сравнивайте целые числа, а не результаты арифметики с плавающей точкой.
Чек-лист перед переключением на боевой ключ
| Пункт | Как проверить |
|---|---|
| Подпись вебхука проверяется | Отправьте запрос с неверной подписью — должен вернуться 4xx, статус не меняется |
| Двойной вебхук не дублирует отгрузку | Переотправьте вебхук из ЛК шлюза дважды — заказ отгружается один раз |
| Сумма сверяется с сервером | Подмените сумму в форме — платёж на заниженную сумму не проходит проверку |
| Эндпоинт вебхука вне CSRF | Тестовый вебхук не отдаёт 419 |
| HTTPS на эндпоинте | Большинство шлюзов не шлют вебхуки на http |
| Логи без карт и CVV | В логах нет номеров карт, только маскированные значения и id транзакций |
| Таймаут при создании платежа обрабатывается | Оборванный запрос не оставляет заказ в подвешенном состоянии навсегда |
| Незавершённые платежи отменяются | Есть джоба, помечающая старые pending как canceled по таймауту шлюза |
Логи и PCI DSS: что писать нельзя
Полный номер карты (PAN), CVV/CVC и срок действия нельзя логировать, хранить в БД или передавать в свои сервисы — это требование PCI DSS. Правильная архитектура вообще не пропускает данные карты через ваш сервер: пользователь вводит их в форме или на странице самого шлюза, а вам приходит только токен и результат. Если карта проходит через ваш бэкенд — область сертификации PCI DSS резко расширяется, и это отдельный большой разговор с эквайером.
В логах платежей полезны: id транзакции, статус, сумма, время, тело вебхука без чувствительных полей. Проверьте, что стандартное логирование запросов Laravel и ваш APM не пишут тело платёжных форм целиком.
Что делать после запуска
Первую неделю на боевом ключе держите под наблюдением три вещи: расхождения сумм в логах, платежи, застрявшие в pending дольше времени жизни сессии оплаты, и повторные вебхуки. Заведите ежедневную сверку: сумма ваших succeeded-платежей за день должна сходиться с выпиской из личного кабинета шлюза. Настройте алерт на любой платёж, где сумма вебхука не совпала с ожидаемой — это либо баг, либо попытка подмены. Возвраты и частичные возвраты — отдельная тема, здесь мы их не разбирали, но модель платежей выше к ним уже готова.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Documentation — CSRF Protection, PCI Security Standards Council — Official Site
Частые вопросы
А если у меня Laravel 10, а не 11 — код подойдёт?
Обязательно ли использовать вебхук, если у шлюза есть return_url?
Что будет, если не проверять идемпотентность вебхука?
Грозит ли мне что-то, если я логирую номера карт?
Почему нельзя хранить сумму во float?
Как обработать ситуацию, когда деньги списались, а ответ от шлюза не пришёл?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.