Главная/Статьи/Интеграция платежей в Laravel — ЮKassa и Stripe

Интеграция платежей в Laravel — ЮKassa и Stripe

Принимаем платежи в Laravel-приложении: ЮKassa для России, Stripe для международных. Webhook-обработка, рекуррентные платежи, refunds.

ДМ
Дмитрий Мещеряков
📅 10 июня 2026 г.📖 8 мин чтения

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

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

Выбор платёжной системы

СистемаРегионКомиссияОсобенности
ЮKassaРоссия2.8-3.5%СБП, рассрочка
StripeМир2.9% + 30¢Лучшее API
CloudPaymentsРоссия2.7%Быстрое подключение
ТинькоффРоссияот 1.79%Интеграция с банком
💡 Совет

Рекомендация: Для России — ЮKassa (популярность, СБП). Для международных — Stripe (лучший DX).

ЮKassa — установка

bash
composer require yoomoney/yookassa-sdk-php
bash
# .env
YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_key

Сервис для ЮKassa

php
// app/Services/YooKassaService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Payment;
use YooKassa\Client;
use YooKassa\Model\Notification\NotificationSucceeded;
use YooKassa\Model\Notification\NotificationWaitingForCapture;
class YooKassaService
{
    private Client $client;
    public function __construct()
    {
        $this->client = new Client();
        $this->client->setAuth(
            config('services.yookassa.shop_id'),
            config('services.yookassa.secret_key')
        );
    }
    /**
     * Создание платежа
     */
    public function createPayment(Order $order, string $returnUrl): array
    {
        $idempotenceKey = uniqid('', true);
        $payment = $this->client->createPayment([
            'amount' => [
                'value' => number_format($order->total / 100, 2, '.', ''),
                'currency' => 'RUB',
            ],
            'confirmation' => [
                'type' => 'redirect',
                'return_url' => $returnUrl,
            ],
            'capture' => true,
            'description' => "Заказ #{$order->id}",
            'metadata' => [
                'order_id' => $order->id,
            ],
            'receipt' => $this->buildReceipt($order),
        ], $idempotenceKey);
        // Сохраняем платёж в БД
        Payment::create([
            'order_id' => $order->id,
            'external_id' => $payment->getId(),
            'amount' => $order->total,
            'status' => $payment->getStatus(),
        ]);
        return [
            'id' => $payment->getId(),
            'confirmation_url' => $payment->getConfirmation()->getConfirmationUrl(),
        ];
    }
    /**
     * Возврат платежа
     */
    public function refund(Payment $payment, ?int $amount = null): bool
    {
        $idempotenceKey = uniqid('refund_', true);
        $refund = $this->client->createRefund([
            'payment_id' => $payment->external_id,
            'amount' => [
                'value' => number_format(($amount ?? $payment->amount) / 100, 2, '.', ''),
                'currency' => 'RUB',
            ],
        ], $idempotenceKey);
        return $refund->getStatus() === 'succeeded';
    }
    /**
     * Обработка webhook
     */
    public function handleWebhook(array $data): void
    {
        $notification = match ($data['event']) {
            'payment.succeeded' => new NotificationSucceeded($data),
            'payment.waiting_for_capture' => new NotificationWaitingForCapture($data),
            default => null,
        };
        if (!$notification) {
            return;
        }
        $paymentData = $notification->getObject();
        $orderId = $paymentData->getMetadata()->order_id;
        $payment = Payment::where('external_id', $paymentData->getId())->first();
        if (!$payment) {
            logger()->error('Payment not found', ['id' => $paymentData->getId()]);
            return;
        }
        $payment->update([
            'status' => $paymentData->getStatus(),
            'paid_at' => $paymentData->getStatus() === 'succeeded' ? now() : null,
        ]);
        if ($paymentData->getStatus() === 'succeeded') {
            $payment->order->update(['status' => 'paid']);
            event(new OrderPaid($payment->order));
        }
    }
    /**
     * Чек для 54-ФЗ
     */
    private function buildReceipt(Order $order): array
    {
        $items = $order->items->map(fn($item) => [
            'description' => mb_substr($item->product->name, 0, 128),
            'quantity' => (string) $item->quantity,
            'amount' => [
                'value' => number_format($item->price / 100, 2, '.', ''),
                'currency' => 'RUB',
            ],
            'vat_code' => 1, // Без НДС
            'payment_subject' => 'commodity',
            'payment_mode' => 'full_payment',
        ])->toArray();
        return [
            'customer' => [
                'email' => $order->user->email,
            ],
            'items' => $items,
        ];
    }
}

Контроллер платежей

php
// app/Http/Controllers/PaymentController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\YooKassaService;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
    public function __construct(
        private YooKassaService $yookassa
    ) {}
    public function create(Order $order)
    {
        $this->authorize('pay', $order);
        if ($order->status !== 'pending') {
            return back()->with('error', 'Заказ уже оплачен или отменён');
        }
        $payment = $this->yookassa->createPayment(
            $order,
            route('payment.callback', $order)
        );
        return redirect($payment['confirmation_url']);
    }
    public function callback(Order $order)
    {
        return view('payment.result', [
            'order' => $order->fresh(),
        ]);
    }
    public function webhook(Request $request)
    {
        $this->yookassa->handleWebhook($request->all());
        return response('OK', 200);
    }
}

Stripe — установка

bash
composer require stripe/stripe-php
bash
# .env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Сервис для Stripe

php
// app/Services/StripeService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Payment;
use Stripe\StripeClient;
use Stripe\Webhook;
class StripeService
{
    private StripeClient $stripe;
    public function __construct()
    {
        $this->stripe = new StripeClient(config('services.stripe.secret'));
    }
    /**
     * Создание Checkout Session
     */
    public function createCheckoutSession(Order $order): string
    {
        $lineItems = $order->items->map(fn($item) => [
            'price_data' => [
                'currency' => 'usd',
                'product_data' => [
                    'name' => $item->product->name,
                ],
                'unit_amount' => $item->price, // в центах
            ],
            'quantity' => $item->quantity,
        ])->toArray();
        $session = $this->stripe->checkout->sessions->create([
            'payment_method_types' => ['card'],
            'line_items' => $lineItems,
            'mode' => 'payment',
            'success_url' => route('payment.success', ['order' => $order->id]),
            'cancel_url' => route('payment.cancel', ['order' => $order->id]),
            'metadata' => [
                'order_id' => $order->id,
            ],
            'customer_email' => $order->user->email,
        ]);
        Payment::create([
            'order_id' => $order->id,
            'external_id' => $session->id,
            'amount' => $order->total,
            'status' => 'pending',
        ]);
        return $session->url;
    }
    /**
     * Payment Intent (для кастомной формы)
     */
    public function createPaymentIntent(Order $order): array
    {
        $intent = $this->stripe->paymentIntents->create([
            'amount' => $order->total,
            'currency' => 'usd',
            'metadata' => [
                'order_id' => $order->id,
            ],
        ]);
        return [
            'clientSecret' => $intent->client_secret,
        ];
    }
    /**
     * Обработка webhook
     */
    public function handleWebhook(string $payload, string $signature): void
    {
        $event = Webhook::constructEvent(
            $payload,
            $signature,
            config('services.stripe.webhook_secret')
        );
        match ($event->type) {
            'checkout.session.completed' => $this->handleSessionCompleted($event->data->object),
            'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
            'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
            default => null,
        };
    }
    private function handleSessionCompleted($session): void
    {
        $payment = Payment::where('external_id', $session->id)->first();
        if ($payment) {
            $payment->update(['status' => 'succeeded', 'paid_at' => now()]);
            $payment->order->update(['status' => 'paid']);
            event(new OrderPaid($payment->order));
        }
    }
    private function handlePaymentSucceeded($intent): void
    {
        $orderId = $intent->metadata->order_id;
        $order = Order::find($orderId);
        if ($order) {
            $order->update(['status' => 'paid']);
            event(new OrderPaid($order));
        }
    }
    private function handlePaymentFailed($intent): void
    {
        $orderId = $intent->metadata->order_id;
        logger()->warning('Payment failed', ['order_id' => $orderId]);
    }
    /**
     * Возврат
     */
    public function refund(string $paymentIntentId, ?int $amount = null): bool
    {
        $params = ['payment_intent' => $paymentIntentId];
        if ($amount) {
            $params['amount'] = $amount;
        }
        $refund = $this->stripe->refunds->create($params);
        return $refund->status === 'succeeded';
    }
}

Роуты

php
// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/orders/{order}/pay', [PaymentController::class, 'create'])
        ->name('payment.create');
    Route::get('/orders/{order}/payment/callback', [PaymentController::class, 'callback'])
        ->name('payment.callback');
});
// routes/api.php
Route::post('/webhooks/yookassa', [PaymentController::class, 'webhookYookassa']);
Route::post('/webhooks/stripe', [PaymentController::class, 'webhookStripe']);

Миграция для платежей

php
// database/migrations/create_payments_table.php
Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained()->cascadeOnDelete();
    $table->string('external_id')->unique();
    $table->integer('amount');
    $table->string('status')->default('pending');
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});
⚠️ Важно

Webhook безопасность:

  • Всегда проверяйте подпись webhook
  • Используйте HTTPS
  • Не доверяйте query-параметрам — проверяйте статус через API

Четыре правила приёма платежей

Это то, что определяет, будет интеграция надёжной или нет. Конкретный платёжный провайдер здесь неважен — правила одинаковы для всех.

Источником истины является только webhook. return_url — это страница, на которую браузер вернул пользователя, и она ничего не доказывает: пользователь мог закрыть вкладку сразу после оплаты (и тогда возврата не будет вовсе) или подобрать адрес вручную (и тогда возврат будет без оплаты). Заказ переводится в оплаченный только по подтверждённому уведомлению от платёжной системы.

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

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

Отвечайте быстро и логируйте всё. Тяжёлая работа в обработчике уведомления приводит к таймауту, таймаут — к повтору, повтор — к дублям. Принимайте уведомление, сохраняйте, отвечайте 200, обрабатывайте отдельно. А полный лог всех платёжных операций — это то, чем вы будете доказывать свою правоту в споре с клиентом или банком; вести его нужно с первого дня.

⚠️ Важно

Про переходы между статусами. Уведомления могут прийти не в том порядке, в каком происходили события: сначала об успешной оплате, потом — задержавшееся о создании платежа. Обработчик, слепо применяющий последний пришедший статус, откатит оплаченный заказ обратно в ожидание.

Опишите допустимые переходы явно и игнорируйте недопустимые: из «оплачен» нельзя вернуться в «ожидает оплаты», из «возвращён» — в «оплачен».

Итоги

ОперацияЮKassaStripe
Создание платежаcreatePayment()checkout.sessions.create()
Проверка статусаWebhook / getPaymentInfo()Webhook / retrieve()
ВозвратcreateRefund()refunds.create()
РекуррентsavePaymentMethodSubscriptions API

Чек-лист перед приёмом реальных денег:

  1. Ключи в .env, боевые и тестовые разделены, боевые не лежат ни в репозитории, ни в чате.
  2. Подпись уведомления проверяется — иначе любой может прислать вам «оплату». Проверка выполняется до всякой обработки, а сравнение подписей — через hash_equals().
  3. Статус меняется только по webhook, не по return_url.
  4. Сумма и валюта сверяются с заказом.
  5. Обработчик идемпотентен по идентификатору платежа.
  6. Недопустимые переходы статусов игнорируются.
  7. Всё логируется — запрос, ответ, решение, время.
  8. Проверено на реальном платеже в боевом режиме: тестовый контур не воспроизводит всё, и первый рубль стоит провести самому. Заодно проверьте возврат — обнаруживать, что он не работает, в момент, когда его требует клиент, крайне неприятно.
  9. Для 54-ФЗ формируются чеки — и это отдельная задача со своими сроками и требованиями к составу данных, а не галочка в настройках.
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.