Олексій Синяєв
Найняти
Навігація сторінкою статті
Статті 9 хв читання

Підписки в 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 здається. Вирішіть свідомо і закодуйте це в одному місці.

Схожі статті

Оновлено:

Поділитися статтею

LinkedIn X Email

Зв'язатися

Працюєте над схожою задачею? Давайте обговоримо.

Відкритий до розмови про архітектуру, Laravel, WordPress, продуктивність і практичні інженерні задачі.

Зв'язатися Переглянути кейси

Дивіться також

Статті

Як зрозуміти, що AI-агент втратив контекст: state-canary в AGENTS.md і CLAUDE.md

State-canary — простий observability-патерн для AI coding agents: один рядок стану в кожній відповіді…
Статті

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

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

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

Покроковий мануал: Hermes Agent і DeepSeek на Ubuntu, закритий Telegram-бот, локальний faster-whisper, Markdown vault,…