Подписка в Stripe связывает три сущности: Customer платит, Price описывает, что и как часто повторять, Subscription склеивает их в расписание. Дальше Stripe сам крутит цикл: в нужный день выпускает Invoice, списывает с сохранённого метода оплаты, шлёт webhook-события о каждом шаге. Ваша задача на PHP – создать эти объекты и реагировать на события.
Ниже – рабочие куски: создание Subscription так, чтобы первый платёж прошёл 3D Secure; отмена без потери уже оплаченного периода; смена плана с proration; события, на которые действительно стоит подписаться; и Customer Portal, чтобы не собирать UI управления подпиской самостоятельно.
Примеры ориентированы на API-версию
2025-03-31и новее. Если аккаунт запинен на более раннюю версию, в релизе 2025-03-31 поменялись два важных куска: поле платежа наInvoiceпереехало (вместоpayment_intentтеперьconfirmation_secretплюс под-ресурсpayments), а эндпоинтInvoice::upcomingпереименован вInvoice::createPreview. Про миграцию – в последнем разделе.
Поддерживает ли Stripe PHP?
Да. stripe/stripe-php – официальный SDK на Packagist, поддерживается самой Stripe:
composer require stripe/stripe-php
Все примеры ниже предполагают подгруженный SDK и установленный секретный ключ в bootstrap:
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
Если проект пока без Stripe, и первый PaymentIntent ещё не собран – начинать стоит со статьи про интеграцию Stripe в PHP. Подписки надстраиваются поверх базовой платёжной интеграции.
Три объекта в голове
Subscription – это связующий объект. Его нельзя создать на пустом месте: нужен Customer (кого списывать, какой метод оплаты у него по умолчанию) и хотя бы один Price (сколько, в какой валюте, с какой периодичностью). Порядок сущностей такой:
Productописывает, что вы продаёте (“Pro plan”, “Gold tier”). Создаётся один раз в Dashboard или через API.Priceкрепит к Product деньги: сумма, валюта, интервал. У одного Product обычно несколько Price – месячный, годовой, разные валюты.Customer– покупатель. Хранит email, дефолтный метод оплаты и список своих подписок.Subscription– сам рекуррентный платёж: Customer, один или несколько Price, расписание, статус.
Объект Plan из старых туториалов – deprecated. API всё ещё принимает plan-ID для совместимости, но новые интеграции пишутся на Product + Price. Если в гайде 2019 года встречается $stripe->plans->create(...) – мысленно заменяйте на Price.
Минимальная подписка
Кратчайший рабочий флоу: создать Customer, привязать PaymentMethod, создать Subscription и развернуть первый invoice так, чтобы client_secret для 3D Secure прилетел в том же ответе:
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$customer = \Stripe\Customer::create([
'email' => $user->email,
'payment_method' => $paymentMethodId, // прилетает с фронта из Stripe.js
'invoice_settings' => [
'default_payment_method' => $paymentMethodId,
],
]);
$subscription = \Stripe\Subscription::create([
'customer' => $customer->id,
'items' => [[ 'price' => 'price_YOUR_MONTHLY_ID' ]],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [ 'save_default_payment_method' => 'on_subscription' ],
'expand' => ['latest_invoice.confirmation_secret'],
]);
$clientSecret = $subscription->latest_invoice->confirmation_secret->client_secret;
// отдаём $clientSecret клиенту; stripe.confirmCardPayment закроет 3DS
Три флага несут на себе всю логику.
payment_behavior: 'default_incomplete' велит Stripe оставить Subscription в статусе incomplete, если первый invoice требует аутентификации (3D Secure, SCA). Без этого флага вызов падает с ошибкой, как только банк просит подтверждение, и клиент видит невнятное сообщение. С флагом на invoice возвращается confirmation_secret с client_secret, и подтверждение завершается в браузере.
expand: ['latest_invoice.confirmation_secret'] подгружает этот secret в ответ. На текущих API-версиях путь – latest_invoice.confirmation_secret.client_secret, а не latest_invoice.payment_intent.client_secret. Именно поэтому в 2025 году у множества скопированных сниппетов отвалилась эта строчка.
save_default_payment_method: 'on_subscription' после удачной оплаты первого invoice делает этот метод дефолтным на Customer. Продления начнут списывать автоматически. Без флага следующий цикл упадёт с ошибкой “no payment method on file”, хотя первый платёж прошёл.
Subscription стартует в incomplete. Как только клиентское подтверждение прошло, Stripe переводит её в active и стреляет customer.subscription.created плюс invoice.paid.
Подписка через Stripe Checkout
Если не хочется собирать PaymentMethod вручную, Checkout забирает на себя весь флоу. line_items указывает на рекуррентную Price, mode переключается на подписочный:
$session = \Stripe\Checkout\Session::create([
'mode' => 'subscription',
'line_items' => [[ 'price' => 'price_YOUR_MONTHLY_ID', 'quantity' => 1 ]],
'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => 'https://example.com/pricing',
]);
Stripe сам создаст Customer, соберёт PaymentMethod, прогонит 3DS, создаст Subscription. Передавать payment_intent_data вместе с 'mode' => 'subscription' нельзя – API отдаст invalid_request. Если нужна metadata или trial-настройки на Subscription – есть отдельный subscription_data с той же формой. Подробнее про режимы – в статье про Checkout.
Webhook-события, которые имеют значение
Подписки порождают больше событий, чем любой другой объект Stripe, и большинство из них можно игнорировать. Минимальный рабочий набор:
| Событие | Когда стреляет | Зачем реагировать |
|---|---|---|
customer.subscription.created | Subscription впервые перешла в active | Выдать доступ к платному |
customer.subscription.updated | Изменился статус, items или cancel-флаг | Обработать апгрейд, даунгрейд, паузу |
customer.subscription.deleted | Subscription завершена (отмена или исчерпаны повторы) | Снять доступ |
invoice.paid | Invoice оплачен (первый и каждое продление) | Продлить доступ ещё на цикл |
invoice.payment_failed | Smart Retries сдались или очередная попытка не прошла | Известить клиента, запустить dunning |
customer.subscription.trial_will_end | За три дня до конца триала (или сразу, если триал короче) | Напомнить привязать карту |
Подписываться стоит именно на эти, а не на весь namespace. customer.subscription.pending_update_applied, .pending_update_expired, .resumed – реальные события, но не слушайте их, пока не используете соответствующие фичи.
Порядок доставки не гарантирован. В патологических случаях Stripe может доставить customer.subscription.updated раньше, чем customer.subscription.created. Каждое событие – самодостаточный факт, а не шаг последовательности. Идемпотентность по event.id и проверка подписи на сыром body – в статье про webhooks, без этих двух вещей в прод выходить нельзя.
Каноническое событие “клиент заплатил” – это invoice.paid, не customer.subscription.updated. Subscription, перешедшая в active, не означает, что деньги ушли – только что Stripe поставил её в расписание. Разблокировать платную функциональность надо по invoice-событию.
Отмена: два режима
У отмены два сценария с принципиально разными последствиями.
Мгновенная отмена заканчивает Subscription сразу и прекращает будущее выставление счетов. Клиент теряет доступ в тот же миг, даже если месяц уже оплачен:
\Stripe\Subscription::cancel($subscriptionId);
Отмена в конце периода держит Subscription активной до конца оплаченного цикла, после чего завершает без нового списания:
\Stripe\Subscription::update($subscriptionId, [
'cancel_at_period_end' => true,
]);
В большинстве потребительских сценариев нужен второй вариант: клиент оплатил по 30-е – оставьте доступ до 30-го. Вызов cancel() сжигает уже оплаченный месяц и оставляет вас с ручным возвратом.
Откатить отложенную отмену – тем же update, в обратную сторону: cancel_at_period_end: false. Stripe снова считает подписку нормальной и выставит счёт в следующем цикле.
При установке cancel_at_period_end Stripe стреляет customer.subscription.updated, а customer.subscription.deleted прилетит только в момент фактического завершения в конце периода. По updated-событию доступ отнимать нельзя – это сломает саму идею отмены в конце периода. Снимать доступ строго по deleted.
Триалы
У Stripe два способа описать триал, и они разные:
// Относительный: триал длится столько дней от момента создания
'trial_period_days' => 14,
// Абсолютный: триал заканчивается в конкретный Unix-таймстамп
'trial_end' => strtotime('2026-05-01T00:00:00Z'),
trial_period_days – обычный сценарий “зарегистрировался, получил 14 дней”. trial_end нужен, когда триал надо совместить с календарной датой: маркетинговая акция с дедлайном или продление триала одному конкретному клиенту без изменения остальных. Оба параметра одновременно на одной Subscription – invalid_request.
Раньше триал требовал привязанную карту: с неё ничего не списывали во время триала, а на 15-й день шло первое списание. Теперь можно стартовать триал без метода оплаты, выставив trial_settings.end_behavior.missing_payment_method в 'cancel', 'pause' или 'create_invoice'. Это режим “free trial без ввода карты”. Без этой настройки Checkout по-прежнему откажется создавать Session, если карта не прикреплена.
За три дня до конца триала Stripe стреляет customer.subscription.trial_will_end. Для триалов короче трёх дней – сразу на создании Subscription. Это сигнал отправить email, напомнить о сумме списания, при необходимости предложить скидку за продление. Молчаливая трансформация из “бесплатно” в “списали” – частая причина чарджбеков, и trial_will_end – ваш шанс её избежать.
Смена плана и proration
Price на работающей Subscription можно заменить. Меняются деньги:
$subscription = \Stripe\Subscription::retrieve($subscriptionId);
\Stripe\Subscription::update($subscriptionId, [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID', // переход с месячного на годовой
]],
'proration_behavior' => 'create_prorations',
]);
items->data[0]->id работает для Subscription с единственным item – самый распространённый случай. Если подписка мультитарифная (несколько Price в одном билинг-цикле), нужно подставлять правильный SubscriptionItem ID для каждого изменения – итерируйте items->data и матчите по price->id или любому признаку, который вы помните.
proration_behavior принимает три значения:
create_prorations(по умолчанию) – Stripe считает неиспользованную часть старого плана, зачисляет клиенту кредит, выставляет пропорциональную часть нового. Разница ляжет в следующий invoice.none– proration не считается, следующий invoice выставляется по новому price на полный цикл. Подходит для даунгрейдов, которые должны вступить в силу со следующего цикла, а не сразу.always_invoice– та же математика, что уcreate_prorations, но invoice выставляется немедленно, а не к следующему циклу. Классический апгрейд-флоу: выбрал старший план – заплатил разницу сейчас.
Перед фактическим изменением удобно показать клиенту, сколько с него спишется. Для этого – Invoice::createPreview с теми же параметрами, что и планируемое изменение:
$preview = \Stripe\Invoice::createPreview([
'customer' => $customer->id,
'subscription' => $subscriptionId,
'subscription_details' => [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID',
]],
'proration_behavior' => 'always_invoice',
],
]);
echo "К списанию: {$preview->amount_due} {$preview->currency}";
Вернётся invoice, который Stripe выставил бы, если бы сейчас нажать кнопку. Удобно для подтверждения “сегодня с вас спишется $X за переход”. До релиза 2025-03-31 этот эндпоинт назывался Invoice::upcoming и имел плоский subscription_proration_behavior. Старый вызов ещё работает на запиненных legacy-версиях, но новый код пишется на createPreview.
Как из Subscription дотянуться до Charge
Классический вопрос на Stack Overflow: “создал подписку, где Charge / PaymentIntent?”. Цепочка длинная, потому что Subscription ссылается на Invoice, у Invoice – одна или несколько попыток оплаты, каждая попытка ссылается на PaymentIntent, и уже тот – на Charge.
В релизе 2025-03-31 у Invoice появился под-ресурс payments под множественные (в том числе частичные) попытки оплаты. PaymentIntent больше не лежит напрямую на Invoice – он теперь внутри invoice.payments.data[].payment.payment_intent.
Поле payment.payment_intent приходит строкой-ID, поэтому для PaymentIntent нужен отдельный retrieve. И latest_charge – тоже строка под PaymentIntent, так что Charge вытягивается третьим вызовом:
$subscription = \Stripe\Subscription::retrieve([
'id' => $subscriptionId,
'expand' => ['latest_invoice.payments'],
]);
$payment = $subscription->latest_invoice->payments->data[0]->payment;
$pi = \Stripe\PaymentIntent::retrieve($payment->payment_intent);
$charge = \Stripe\Charge::retrieve($pi->latest_charge);
Два миграционных момента, на которые натыкаются в старых туториалах:
- До API-версии
2022-11-15у PaymentIntent было полеcharges-список; в этой версии его заменили наlatest_chargeсо строкой-ID. Код вида$pi->charges->data[0]на новых версиях вернёт null. - До релиза 2025-03-31 у Invoice было поле
payment_intent, раскрываемое через expand. На актуальных версиях надо идти черезinvoice.payments.data.
Customer Portal
UI “поменять план, обновить карту, отменить” – это гора форм, диалогов подтверждения и dunning-копирайта. Stripe отгружает его готовым: Customer Portal. Вы редиректите клиента на страницу, размещённую у Stripe, он там делает что нужно, и Stripe стреляет те же webhook-события, которые вы уже обрабатываете.
$portal = \Stripe\BillingPortal\Session::create([
'customer' => $customerId,
'return_url' => 'https://example.com/account',
]);
header('Location: ' . $portal->url, true, 303);
exit;
Вот и вся интеграция для “пусть клиенты отменяют подписку сами, без писем в поддержку”. Что именно клиент может менять, отмена сразу или в конце периода, видны ли ему инвойсы – настраивается один раз в Dashboard (Settings > Billing > Customer portal) или через \Stripe\BillingPortal\Configuration::create. Session и Configuration намеренно разделены: Session – одноразовый редирект, Configuration живёт отдельно и переиспользуется.
Тестирование продлений через test clocks
Гайды первого поколения советуют создать подписку с коротким интервалом и подождать. Проверять продления реальным календарём – не вариант ни в локалке, ни в CI. У Stripe для этого test clocks – симулированное время внутри test-mode, которое вы двигаете вручную:
$clock = \Stripe\TestHelpers\TestClock::create([
'frozen_time' => time(),
]);
$customer = \Stripe\Customer::create([
'email' => '[email protected]',
'test_clock' => $clock->id,
]);
// создаём Subscription на $customer как обычно...
// проматываем на месяц вперёд
$clock->advance([
'frozen_time' => time() + 31 * 86400,
]);
Stripe прогоняет Subscription по новому времени: выписывается invoice, Smart Retries крутят свою логику на неудачных платежах, стреляет customer.subscription.trial_will_end, если триал уходит в прошлое. Работает только в test-mode, но это единственный способ реально погонять продления в CI. У stripe-cli есть команда stripe trigger для синтетических событий, но триггеры – фабрикация; test clocks крутят реальную биллинг-машинерию.
Частые проблемы
invalid_request: payment_intent_data not allowed on subscription
Скопировать сниппет Checkout-сессии из разового платежа и забыть снести оттуда payment_intent_data – прямой путь к этой ошибке. В subscription-режиме есть свой subscription_data с похожей формой. Убрать payment_intent_data, по надобности переписать данные в subscription_data.
latest_invoice->payment_intent = null
На API-версиях 2025-03-31 и новее поле Invoice.payment_intent удалено. Код вида $subscription->latest_invoice->payment_intent->client_secret либо вернёт null, либо прокинет “undefined property” в зависимости от настроек PHP. Переходить на latest_invoice.confirmation_secret.client_secret с соответствующим expand. Если принципиально сидеть на старом поле, пинуйте API-версию на уровне вызова: ['stripe_version' => '2024-12-18']. И знайте, что это временное решение.
Invoice застрял в draft
Если на invoice выставлен auto_advance: false, он сам из черновика не выйдет. Invoice::finalizeInvoice($invoiceId) переведёт в open, Invoice::pay($invoiceId) спишет. Автоматический флоу ставит auto_advance: true по умолчанию; ручной – для сценария “сначала предпросмотр, потом оплата”.
Subscription active, а у клиента нет доступа
active значит “Stripe ведёт биллинг по этой подписке”. Это не значит, что текущий invoice оплачен. Subscription может быть active с упавшим latest_invoice, пока Smart Retries докручивают повторные попытки. Доступ привязывать к событию invoice.paid и current_period_end подписки, а не к одному только status === 'active'.
Продление списалось со старой карты
Карты протухают. У Stripe работает card updater, который прозрачно обновляет реквизиты у ряда эмитентов, но не у всех. Когда не сработал – продление падает, клиент получает invoice.payment_failed. Dunning строится вокруг этого события: письмо, предложение обновить карту, опционально grace-период с сохранением доступа.
Что дальше
- Webhooks Stripe в PHP – проверка подписи и идемпотентные обработчики. На объёме событий от подписок идемпотентность перестаёт быть опцией.
- Stripe Checkout в PHP – Hosted, Embedded, Custom-режимы.
mode: 'subscription'– быстрый путь, если не хочется собирать форму оплаты руками. - Stripe в Laravel без Cashier – сервис-провайдер с
StripeClientкак синглтоном, исключение webhook из CSRF, ловушка PII в очереди и коллизия пространств имён с Eloquent-модельюCustomer. - Детальная математика proration (апгрейды в середине цикла, смешанные валюты, credit notes) – в собственной документации Stripe.
Нашли неточность на этой странице?
Сообщить об ошибке