Алексей Синяев
Навигация по странице статьи
Статьи 9 мин чтения March 21, 2026

Подписки в Laravel с Cashier и Stripe

Коротко Cashier превращает счастливый путь в три строки кода. Продакшен-биллинг — это всё, что вокруг этого пути: вебхуки, SCA, неудавшиеся платежи, окончание триалов и смена тарифов. Источник истины — Stripe. Ваша база данных —…

Содержание

Коротко

  • Cashier превращает счастливый путь в три строки кода. Продакшен-биллинг — это всё, что вокруг этого пути: вебхуки, SCA, неудавшиеся платежи, окончание триалов и смена тарифов.
  • Источник истины — Stripe. Ваша база данных — это read-модель, которую синхронизируют вебхуки, поэтому выдавайте доступ в обработчике вебхука, а не в контроллере, вызвавшем create().
  • Вебхуки приходят минимум один раз и не по порядку. Любой побочный эффект (начисление кредитов, отправка письма) должен быть идемпотентным, иначе он сработает дважды.
  • Привязывайте доступ к состоянию подписки (subscribed(), onGracePeriod()), а не к булеву флагу, который выставляете сами. Машина состояний — это и есть продукт.

Первая фича с подписками, которую я выкатил, отлично работала в демо и сломалась в первую же неделю с настоящими клиентами. В демо использовалась тестовая карта из США, подписка сразу переходила в active и доступ выдавался немедленно. Потом зарегистрировался клиент из Испании, его банк потребовал 3D Secure, подписка оказалась в статусе incomplete, а мой код уже выставил флаг is_premium в true прямо в контроллере. У человека был доступ, за который он не заплатил. Через неделю продление не прошло, Stripe перевёл подписку в past_due, и приложение этого не заметило, потому что я читал свой флаг, а не состояние подписки.

Ничего из этого не является проблемой Cashier. Cashier действительно хорош. Проблема в том, что каждый туториал — включая тот, которым была эта статья, — останавливается на newSubscription()->create() и называет это исчерпывающим руководством. Эти три строки — простые 10%. А это остальные 90%: поток вебхуков, состояния подписки, которые реально приходится обрабатывать, и как устроить код так, чтобы смена цены не расползлась по двадцати контроллерам.

Что Cashier на самом деле даёт (и чего не даёт)

Cashier — это обёртка над Stripe API, которая отображает биллинговые объекты Stripe на модели Eloquent. Он даёт трейт Billable, три таблицы в базе, выразительные методы вроде subscribed() и swap() и — эту часть недооценивают — контроллер вебхуков, который автоматически держит ваши локальные таблицы в синхронизации со Stripe.

Чего он не даёт: решения о том, когда неудавшийся платёж должен отозвать доступ, UI для подтверждения SCA, идемпотентной бизнес-логики и какого-либо мнения о том, как моделировать права доступа. Это на вас. Cashier берёт на себя биллинговую сантехнику; продуктовые правила вокруг биллинга проектировать всё равно вам.

Минимальная настройка для продакшена

Установите пакет и опубликуйте его миграции. Новые версии Cashier используют vendor:publish вместо старой команды cashier:table:

Bash
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

Это создаёт таблицы subscriptions и subscription_items и добавляет столбцы Stripe в таблицу users. Затем — окружение, и обратите внимание на третью переменную, про которую забывают, пока вебхуки молча не отвалятся:

Code
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Добавьте трейт Billable к модели, которой принадлежат биллинговые связи. Обычно это User, но в B2B-продукте часто Team или Account — решите это заранее, потому что переезжать потом больно:

Code
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Создание подписки — и почему счастливый путь врёт

Выразительный вызов, который вам показывают все:

Code
$user
    ->newSubscription('default', config('billing.prices.premium_monthly'))
    ->create($paymentMethodId);

Уже две вещи отличаются от версии из туториала. Цена берётся из конфига, а не из захардкоженной строки, потому что ID цен различаются между тестовым и боевым режимами Stripe и снова изменятся при изменении тарифов. И результат create() не гарантирует активную подписку.

Strong Customer Authentication ломает счастливый путь

Европейские карты (и всё чаще другие) требуют подтверждения 3D Secure. Когда это происходит, create() завершается успешно, но статус подписки — incomplete, а не active. Клиенту ещё нужно подтвердить платёж. Если вы выдаёте доступ сразу после возврата create(), вы выдаёте доступ тем, кто на самом деле не заплатил.

Именно поэтому доступ здесь выдавать нельзя. Задача контроллера — начать подписку и, если платёж требует подтверждения, передать клиенту payment intent для завершения. Доступ выдаётся позже, когда Stripe подтвердит платёж и сообщит об этом через вебхук.

Вебхуки — настоящий источник истины

Cashier поставляет контроллер вебхуков. Направьте эндпоинт вебхука Stripe на /stripe/webhook, задайте STRIPE_WEBHOOK_SECRET и исключите этот маршрут из CSRF-защиты. Из коробки Cashier слушает события подписок и счетов и держит ваши локальные таблицы корректными: продление, отмена, неудавшийся платёж или подтверждение SCA — всё обновляет строку в вашей базе, и вам не нужно писать ни строчки.

Чего Cashier не может — выполнять ваши побочные эффекты: создание рабочего пространства, начисление API-кредитов, отправку приветственного письма. Их вы добавляете, слушая событие Cashier WebhookReceived:

Code
use Laravel\Cashier\Events\WebhookReceived;

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.payment_succeeded') {
            $this->provisionAccess($event->payload['data']['object']);
        }
    }
}

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

Вебхуки приходят минимум один раз и не по порядку

Stripe иногда доставляет одно и то же событие дважды и не гарантирует порядок. Вы можете получить subscription.updated раньше, чем subscription.created, за которым он логически следует. Каждый обработчик должен быть безопасным к повторному запуску и к запуску вне очереди.

Делайте побочные эффекты идемпотентными. Прежде чем начислить 500 API-кредитов, проверьте, не был ли этот счёт уже обработан — привяжитесь к ID счёта или события Stripe, сохраните факт обработки и сделайте no-op при повторной доставке. Приветственное письмо, защищённое отметкой welcomed_at, не уйдёт дважды. Цена забывчивости — дважды начисленные кредиты и дублирующиеся письма, и узнаете вы об этом от раздражённого клиента.

Состояния подписки, которые реально приходится обрабатывать

«Подписан или нет» — это два состояния. У настоящего биллинга Stripe их минимум шесть, и каждое меняет поведение продукта. Вот таблица, которую я держу под рукой, когда настраиваю контроль доступа:

СостояниеЧто означаетПроверка CashierДавать доступ?
trialingИдёт триал, оплаты ещё не былоonTrial()Да
activeОплачено и актуальноsubscribed()Да
incompleteПервый платёж требует подтверждения SCAhasIncompletePayment()Нет
past_dueПродление не прошло; Stripe повторяет попыткиsubscription()->past_due()На ваше усмотрение (см. ниже)
отменена, льготный периодОтменена, но оплачена до конца периодаonGracePeriod()Да, до конца периода
отменена, завершенаПериод закончился, доступ истёкsubscribed() вернёт falseНет

Строка past_due — это продуктовое решение, а не техническое. Stripe прогоняет повторные попытки списания в течение нескольких дней. Вы отрезаете доступ в момент неудачи продления или держите клиента до конца окна повторов и отзываете доступ только когда Stripe сдаётся и переходит в canceled? Немедленный отрез снижает потери выручки, но наказывает клиента, у которого просто истёк срок карты. Большинство SaaS оставляют доступ на время окна повторов и показывают баннер «платёж не прошёл, обновите карту». Правильного ответа нет; есть решение, которое стоит принять осознанно, а не случайно.

Триалы, смена тарифов и отмена

Триалы бывают двух видов. Триал с картой заранее использует trialDays() при создании. Триал без карты — пустить людей до запроса оплаты — использует отметку времени на модели и пока без подписки в Stripe:

Code
// Карта обязательна заранее
$user->newSubscription('default', $priceId)
    ->trialDays(14)
    ->create($paymentMethodId);

// Без карты заранее (общий триал)
$user->trial_ends_at = now()->addDays(14);
$user->save();

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

Смена тарифа — это один вызов, но именно пропорциональный пересчёт (proration) порождает тикеты в поддержку:

Code
$user->subscription('default')->swap($newPriceId);            // по умолчанию с пропорцией
$user->subscription('default')->noProrate()->swap($newPriceId); // без пропорции

Отмена — это место, где команды тихо теряют лояльность клиентов. cancel() не завершает подписку немедленно — она отменяется в конце периода, поэтому клиент сохраняет доступ, за который уже заплатил, и именно это отражает onGracePeriod(). cancelNow() отзывает доступ сразу и ничего не возвращает. Берите cancel(), если у вас нет конкретной причины поступить иначе:

Code
$user->subscription('default')->cancel();    // доступ до конца периода
$user->subscription('default')->resume();    // передумали в льготный период
$user->subscription('default')->cancelNow(); // немедленно, без льготного периода

Уберите биллинг-логику за одну границу

Ошибка, которая делает код подписок неподдерживаемым, — это рассыпать проверки $user->subscribed('default') по контроллерам, шаблонам Blade и джобам. В тот день, когда вы добавите годовой тариф или второй продукт, вы будете охотиться за каждой из них.

Держите один метод, отвечающий на продуктовый вопрос — «может ли этот пользователь пользоваться этой фичей?» — и пусть он внутри читает состояние подписки. Всё остальное вызывает этот метод и ничего не знает про Stripe:

Code
public function canAccessPremium(): bool
{
    return $this->subscribed('default')
        || $this->onTrial()
        || $this->subscription('default')?->onGracePeriod();
}

Это та же идея инверсии зависимостей, применённая к биллингу: приложение зависит от устойчивого вопроса, а не от изменчивых деталей того, как Stripe на него отвечает. Если хотите разобраться в рассуждении за этой границей, я разобрал его в статье про внедрение и инверсию зависимостей в Laravel. Держать Stripe за одним сфокусированным сервисом — это ещё и то, что делает сценарии отказа из статьи про безопасность веб-приложений управляемыми: проверка подписи вебхука и контроль доступа живут в одном месте, которое можно проаудировать, а не в двадцати.

Ошибки, которые я вижу в реальном коде подписок на Laravel

Выдача доступа в контроллере

Выдача доступа сразу после create() игнорирует SCA и сбои запроса. Выдавайте в обработчике вебхука.

Неидемпотентные обработчики вебхуков

Доставка минимум один раз означает дубликаты. Привязывайте побочные эффекты к ID события или счёта Stripe и делайте no-op на повторе.

Самодельный флаг is_premium

Он разъезжается со Stripe в момент, когда платёж молча не проходит. Читайте состояние подписки, а не дублируйте его вручную.

Пропуск секрета вебхука

Без STRIPE_WEBHOOK_SECRET кто угодно может слать фальшивые события на ваш эндпоинт. Проверяйте каждую подпись.

Захардкоженные ID цен

Они различаются между тестовым и боевым режимами и меняются при смене цен. Держите их в конфиге, а не в коде.

Отмена как немедленная

Использование cancelNow() по умолчанию выбрасывает доступ, за который клиент уже заплатил. По умолчанию — cancel().

Тестирование потоков подписки, не дожидаясь месяца

Нельзя проверить продления и окончание триала, ожидая реального времени. Test clocks в Stripe позволяют создать клиента, привязанного к симулированным часам, и перемотать их за продление или конец триала, а потом проверить, что ваши вебхуки сработали и логика доступа отреагировала. В сочетании с тестовым режимом Stripe и собственными хелперами Cashier для тестов вы покрываете случаи, которые реально ломаются — неудавшееся продление, подтверждение SCA, окончание триала без карты — в CI-пайплайне, а не в продакшене.

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

FAQ

Где выдавать доступ — в контроллере или в вебхуке?
В вебхуке. Контроллер начинает подписку, но платёж ещё может требовать подтверждения SCA, а HTTP-запрос может упасть уже после успешного create(). Выдача доступа по событию invoice.payment_succeeded в обработчике вебхука — единственная точка, где вы знаете, что клиент действительно заплатил.
Как не дать вебхукам Stripe выполнить мою логику дважды?
Сделайте обработчик идемпотентным. Сохраняйте ID события или счёта Stripe после обработки и проверяйте его перед повторным запуском побочных эффектов. Stripe доставляет минимум один раз, так что дубликаты — это норма, а не исключение.
В чём разница между cancel() и cancelNow() в Cashier?
cancel() отменяет подписку в конце текущего периода, поэтому клиент сохраняет оплаченный доступ — Cashier отражает это как onGracePeriod(). cancelNow() завершает подписку немедленно без льготного периода. По умолчанию используйте cancel().
Почему моя подписка застряла в «incomplete»?
Первый платёж требует Strong Customer Authentication (3D Secure), и клиент его не подтвердил. create() вернулся, но подписка не активна. Покажите клиенту подтверждение payment intent; доступ не следует выдавать, пока платёж не пройдёт.
Отрезать ли доступ в момент, когда платёж не прошёл?
Это продуктовое решение. Stripe повторяет неудавшиеся платежи несколько дней (past_due). Большинство SaaS оставляют доступ на время окна повторов и показывают запрос на обновление карты, отзывая доступ только когда Stripe сдаётся. Решите осознанно и закодируйте это в одном месте.

Похожие статьи

Обновлено: June 17, 2026

Поделиться статьей

LinkedIn X Email

Связаться

Работаете над похожей задачей? Давайте обсудим.

Открыт к обсуждению архитектуры, Laravel, WordPress, производительности и практических инженерных задач.

Связаться Смотреть кейсы

Смотрите также

Статьи

June 14, 2026

Часть 3. Месяц с AI-дневником: как искать связи между сном, стрессом и тренировками

Как анализировать AI-дневник после первого месяца: исправление распознавания, честная рефлексия с источниками, Obsidian, стоимость…
Статьи

June 14, 2026

Часть 2. Hermes Agent + DeepSeek на Ubuntu: полный мануал AI-дневника в Telegram

Пошаговый мануал: Hermes Agent и DeepSeek на Ubuntu, Telegram-бот с закрытым доступом, локальный faster-whisper,…
Статьи

June 14, 2026

Часть 1. Как я превратил старый игровой ноутбук в AI-дневник самочувствия

Как старый Xiaomi Mi Gaming Laptop стал домашним AI-сервером: Hermes Agent, DeepSeek, Telegram и…