Интеграция Laravel с платёжными системами: рабочие приёмы
Дубли списаний, потерянные вебхуки, статус «оплачено» без денег на счёте. Разбираем, как принимать платежи в Laravel так, чтобы деньги и заказы не расходились.

Платёж прошёл, деньги списались, а заказ в базе так и остался в статусе «ожидает оплаты». Или наоборот: пользователь дважды нажал кнопку, ушли два списания, а вернулось одно. Такие расхождения между тем, что видит платёжная система, и тем, что записано у вас, — главная головная боль при интеграции платежей в Laravel. Ниже — приёмы, которые закрывают именно эти дыры: где обычный контроллер даёт сбой и как строить обработку так, чтобы состояние заказа и состояние платежа всегда сходились.
Материал для тех, кто уже поднял Laravel 10.x или 11.x, знает про очереди и миграции, но впервые подключает эквайринг — ЮKassa, CloudPayments, Stripe или Тинькофф. Синтаксис вызовов конкретных SDK у каждого провайдера свой, поэтому имена методов сверяйте с их актуальной документацией: здесь речь про схему обработки, а не про конкретный клиент.
Почему прямой редирект и один контроллер не работают
Очевидное решение выглядит так: пользователь жмёт «Оплатить», вы создаёте платёж, редиректите его на форму провайдера, а на return_url отмечаете заказ оплаченным. Проблема в том, что return_url — это возврат браузера, а не подтверждение оплаты.
- Пользователь может закрыть вкладку после списания и никогда не дойти до
return_url— деньги ушли, заказ не отметился. - URL возврата можно открыть руками, подставив нужный
order_id, и получить «оплачено» без единого рубля. - Браузер может перейти на
return_urlраньше, чем провайдер реально подтвердит платёж на своей стороне.
Источник правды о деньгах — только серверное уведомление провайдера (вебхук, он же callback или notification). return_url нужен исключительно для того, чтобы показать пользователю красивую страницу «спасибо». Финансовое состояние заказа меняет только вебхук.
Правило, которое экономит нервы: браузер приводит пользователя обратно, деньги подтверждает сервер провайдера. Никогда не наоборот.
Разделяем состояние заказа и состояние платежа
Частая ошибка новичков — хранить признак оплаты прямо в заказе флагом is_paid. Как только появляется частичный возврат, повторная попытка оплаты или несколько платёжных попыток по одному заказу, этой модели становится мало.
Заведите отдельную сущность платежа. У заказа — своё состояние (создан, оплачен, отменён, возвращён), у платежа — своё (создан, в обработке, успешен, отклонён, возвращён). Один заказ может иметь несколько платёжных попыток, но успешной — максимум одну.
// Миграция таблицы платежей, Laravel 11.x
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
// Идентификатор платежа на стороне провайдера
$table->string('provider_payment_id')->nullable()->unique();
// Ключ идемпотентности, который мы генерируем сами
$table->uuid('idempotency_key')->unique();
$table->string('status')->default('created');
$table->unsignedBigInteger('amount'); // в копейках, не в рублях
$table->string('currency', 3)->default('RUB');
$table->json('raw_response')->nullable();
$table->timestamps();
});
Сумму держите в минимальных единицах — копейках, а не в рублях с плавающей точкой. Тип float для денег рано или поздно даст расхождение на копейку при округлении, и сверка с провайдером перестанет сходиться.
Идемпотентность: главный приём против дублей
Пользователь нажал «Оплатить» дважды, или ваша очередь обработала одно уведомление повторно после ретрая. Без защиты вы создадите два платежа или дважды спишете со склада товар. Решение — идемпотентность на двух уровнях.
Со стороны создания платежа. Генерируйте idempotency_key (UUID) на момент создания попытки и передавайте его провайдеру. У ЮKassa, Stripe и ряда других есть заголовок идемпотентности — при повторном запросе с тем же ключом провайдер вернёт тот же платёж, а не создаст новый. Точное имя заголовка сверьте в документации конкретного провайдера, оно различается.
Со стороны обработки вебхука. Провайдер может прислать одно и то же уведомление несколько раз — это норма, а не сбой. Обработчик обязан быть идемпотентным: повторное уведомление о том же успешном платеже не должно повторно менять заказ.
// Внутри обработчика вебхука, схематично
DB::transaction(function () use ($providerPaymentId, $newStatus) {
// Блокируем строку платежа на время транзакции
$payment = Payment::where('provider_payment_id', $providerPaymentId)
->lockForUpdate()
->firstOrFail();
// Уже в финальном статусе — уведомление дублирующее, выходим
if (in_array($payment->status, ['succeeded', 'refunded'], true)) {
return;
}
$payment->update(['status' => $newStatus]);
if ($newStatus === 'succeeded') {
$payment->order->markAsPaid();
}
});
lockForUpdate() здесь не для красоты: без блокировки строки два одновременных вебхука (а провайдер вполне может прислать их параллельно) прочитают статус «в обработке» оба, оба пройдут проверку и оба спишут товар.
Проверка подписи вебхука — до любой логики
Эндпоинт вебхука открыт в интернет и не защищён вашей авторизацией. Кто угодно может отправить на него POST с телом «платёж успешен». Поэтому первым делом — проверка подлинности запроса.
Механизмы разные: одни провайдеры подписывают тело запроса HMAC с вашим секретным ключом, другие требуют проверять IP-адреса из своего диапазона, третьи предлагают запросить платёж по API и убедиться в его статусе. Надёжнее всего — не доверять телу уведомления вовсе, а по provider_payment_id из него сделать обратный запрос к API провайдера и взять статус оттуда. Тогда подделка тела бессмысленна.
Эндпоинт вебхука обязательно исключите из CSRF-защиты Laravel — иначе провайдер получит 419. В Laravel 11.x это делается в bootstrap/app.php через validateCsrfTokens(except: [...]); в 10.x и раньше — через свойство $except в VerifyCsrfToken. Синтаксис отличается между версиями, сверьтесь с разделом CSRF в документации вашей версии.
Отвечайте провайдеру быстро, работайте в очереди
Большинство провайдеров ждут ответ 200 на вебхук за несколько секунд. Не получив его, они считают доставку неудачной и шлют уведомление повторно — иногда часами. Если внутри обработчика вы отправляете письмо, генерируете чек и ходите во внешние сервисы, вы рискуете не уложиться в таймаут.
Схема: обработчик валидирует подпись, фиксирует факт оплаты в транзакции и сразу отвечает 200. Всё остальное — письма, чеки для 54-ФЗ, начисление бонусов — уходит в очередь через задания Laravel.
- Приняли вебхук, проверили подлинность.
- В транзакции с блокировкой обновили статус платежа и заказа.
- Диспатчнули задания в очередь (отправка чека, письмо).
- Вернули
200. Провайдер доволен, повторов не будет.
Так медленные операции не влияют на таймаут вебхука, а при падении задания его подхватит механизм повторов очереди, не трогая уже подтверждённую оплату.
Сверка на случай пропущенного вебхука
Вебхук может не дойти: у вас был деплой, упал воркер, провайдер словил сетевую ошибку и исчерпал попытки. Заказ оплачен, а у вас висит «в обработке». Поэтому нужен фоновый механизм сверки.
Планировщиком (schedule) раз в несколько минут выбирайте платежи в статусе «в обработке» старше, скажем, десяти минут и запрашивайте их статус у провайдера напрямую по API. Если провайдер говорит «успешно» — прогоняйте тот же идемпотентный код, что и вебхук. Это подстраховка, которая закрывает разрыв между тем, что провайдер уже знает, и тем, что застряло у вас.
| Способ узнать о результате | Когда срабатывает | Насколько доверять |
|---|---|---|
| return_url (браузер) | Пользователь дошёл до конца | Только для показа страницы, деньги не подтверждает |
| Вебхук провайдера | Основной канал, приходит с сервера | Высокая — после проверки подписи |
| Обратный запрос статуса по API | Проверка внутри вебхука и при сверке | Максимальная — источник правды |
| Фоновая сверка по расписанию | Вебхук не дошёл | Высокая — подстраховка |
Что чаще всего ломается на проде
- Тестовые и боевые ключи перепутаны. Держите их в
.envи никогда не в коде. На стейджинге — только тестовый эквайринг. - Сумма в вебхуке не сверяется с суммой заказа. Провайдер может прислать оплату на сумму, отличную от ожидаемой. Всегда сравнивайте
amountиз уведомления с суммой платежа в базе. - Округление рублей. Считайте в копейках целыми числами до самого вывода пользователю.
- Вебхук закрыт авторизацией или CSRF. Провайдер получает
401/419и уходит в бесконечные повторы. - Логика прямо в контроллере вебхука. Медленные операции ломают таймаут; выносите в очередь.
Что настроить дальше
Когда базовая оплата сходится, займитесь возвратами: частичный возврат — отдельное состояние платежа, а не удаление записи. Дальше — логирование всех уведомлений в raw_response, чтобы при споре с провайдером было что предъявить, и дашборд сверки, показывающий платежи, где ваш статус расходится со статусом провайдера. Если работаете с физлицами в РФ — фискализация по 54-ФЗ обычно тоже вешается на успешный вебхук через очередь, но это отдельная тема, здесь её не разворачиваем.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: Laravel Documentation — Queues, Laravel Documentation — CSRF Protection
Частые вопросы
А если я использую return_url для отметки оплаты — что не так?
Провайдер прислал один и тот же вебхук дважды — это ошибка?
У меня Laravel 10, а не 11 — код подойдёт?
Что делать, если вебхук так и не пришёл, а деньги списались?
Почему нельзя хранить сумму в рублях как float?
Обязательно ли делать обратный запрос статуса, если подпись вебхука уже проверена?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.