Cashier задаёт рамки. Большинство туториалов по связке Laravel и Stripe сразу тянут laravel/cashier-stripe, потому что пакет официальный и половина билинговых экранов там уже собрана – триал, инвойсы, трейт Billable на User. Цена этого удобства – дополнительные миграции, конвенции о том, где должно жить состояние подписки, и обёртка, которой приходится выпускать релиз после каждого мажорного stripe-php. Для SaaS с обычными подписками, который надо запустить побыстрее, Cashier – правильный выбор. Для мультитенантного маркетплейса, разового продукта или команды, у которой уже есть мнение о том, где хранить stripe_customer_id, он мешает.
Эта статья про обратный путь. Ставим stripe/stripe-php напрямую, биндим \Stripe\StripeClient синглтоном в сервис-провайдере, держим контроллеры тонкими поверх сервисного класса и обрабатываем webhooks обычным Laravel-кодом. Cashier всплывёт дважды и оба раза по делу – один раз в сравнении вверху, второй – когда его единственный известный баг на 3DS стоит назвать, чтобы обойти, а не отлаживать.
Примеры нацелены на Laravel 11 и 12 (middleware настраивается в
bootstrap/app.php) и текущую API-версию Stripe (2025-03-31и новее). Где Laravel 10 отличается, есть отдельный раздел в конце.
Есть ли у Laravel пакет для Stripe?
По сути, два. laravel/cashier-stripe – официальный, закрывает подписочный цикл целиком: User::newSubscription()->create($token), PDF-инвойсы, купоны, всё это. spatie/laravel-stripe-webhooks – узкий, только входящая сторона webhook-ов, даёт чистый диспетчер Job-ов на каждое событие поверх stripe/stripe-php. За пределами этих двух есть сам stripe/stripe-php – SDK, который мейнтейнит Stripe, и которым Cashier пользуется под капотом.
Выбор короче, чем пакеты создают впечатление:
| Что нужно | Чем брать |
|---|---|
| SaaS-подписки со стандартными планами, триалами, инвойсами | Cashier |
| Разовые платежи, маркетплейс, usage-billing, Connect | stripe/stripe-php напрямую |
| Только инфраструктура для webhook-ов, остальной Stripe-код свой | spatie/laravel-stripe-webhooks |
| Полный контроль над схемой и логикой, минимум магии фреймворка | stripe/stripe-php напрямую (эта статья) |
Cashier – не тонкая обёртка. Он добавляет в БД таблицы subscriptions, subscription_items и ещё пару других, предполагает что billable-запись – это ваш User, отдаёт объекты Stripe через свой собственный fluent API ($user->subscription('default')->cancel()) и ожидает, что вы будете говорить на его языке. Когда билинг в проект ложится на этот язык – Cashier экономит недели. Когда нет – скажем, вы берёте деньги за места, а один пользователь числится сразу в нескольких командах, или уже смоделировали Stripe-сущности в своём домене – Cashier превращается в код, с которым приходится бороться.
Остальная статья предполагает, что выбор “прямой SDK” уже сделан, и дальше нужна Laravel-специфика: сервис-провайдер, роутинг webhook-а, гигиена очереди, тестируемость.
Установка и конфигурация
Добавляем SDK в composer.json:
composer require stripe/stripe-php
Сам stripe-php терпим к версиям PHP (текущий master объявляет 7.2+), реальный нижний порог определяет фреймворк. Laravel 11 и 12 требуют PHP 8.2+, а Cashier тянется за той версией Laravel, под которую собран.
Ключи кладутся в .env:
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
И наружу через config/services.php, не через константы и не через статические вызовы:
// config/services.php
return [
// ...
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'api_version' => '2025-03-31',
],
];
Причин две. Первая: env() за пределами конфиг-файла возвращает null после того, как отработал php artisan config:cache, потому что Laravel перестаёт грузить .env на буте. Если читать env('STRIPE_SECRET') прямо в контроллере, локально всё работает, а на продакшене отваливается в тот момент, когда кто-то впервые выполнит config:cache. Вторая: config('services.stripe.secret') подменяется в тестах через Config::set(...), со статикой и env() так не получится.
После правки .env на закешированном окружении не забываем пересобрать кеш:
php artisan config:cache
Пропустить эту команду – самая частая причина бага “ключ выкрутили, но ничего его не читает” в Laravel-Stripe-деплоях.
Сервис-провайдер
Статический паттерн \Stripe\Stripe::setApiKey() всё ещё работает, но это глобальное мутирующее состояние, и в мультитенантных конфигурациях (разные Stripe-аккаунты под разные организации) он мешает. Вместо этого биндим \Stripe\StripeClient как синглтон:
// app/Providers/StripeServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
class StripeServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(StripeClient::class, function ($app) {
return new StripeClient([
'api_key' => $app['config']->get('services.stripe.secret'),
'stripe_version' => $app['config']->get('services.stripe.api_version'),
]);
});
}
}
Регистрируем в bootstrap/providers.php (Laravel 11+) или в config/app.php (Laravel 10 и ниже):
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\StripeServiceProvider::class,
];
Теперь контроллеры и сервисы могут type-хинтить StripeClient, и контейнер Laravel сам отдаст сконфигурированный инстанс:
public function __construct(private StripeClient $stripe) {}
// внутри метода:
$customer = $this->stripe->customers->create([
'email' => $user->email,
'metadata' => ['user_id' => (string) $user->id],
]);
Методы ресурсов (customers->create, paymentIntents->retrieve, subscriptions->update и так далее) вызываются с инстанса. Статический API – \Stripe\Customer::create(...) – всё ещё валиден и делает то же самое, но инстансная форма легче подменяется в тестах, чище работает с несколькими аккаунтами и рекомендована самой Stripe для нового кода.
Тонкий сервисный класс
Если пускать всё через сервисный класс, контроллеры остаются читаемыми, а у вас появляется точка для логирования, метрик и ретраев. Начать стоит узко:
// app/Services/Billing/StripeCustomerService.php
namespace App\Services\Billing;
use App\Models\User;
use Stripe\StripeClient;
use Stripe\Customer as StripeCustomer;
class StripeCustomerService
{
public function __construct(private StripeClient $stripe) {}
public function findOrCreate(User $user): StripeCustomer
{
if ($user->stripe_customer_id) {
return $this->stripe->customers->retrieve($user->stripe_customer_id);
}
$customer = $this->stripe->customers->create([
'email' => $user->email,
'name' => $user->name,
'metadata' => ['user_id' => (string) $user->id],
]);
$user->forceFill(['stripe_customer_id' => $customer->id])->save();
return $customer;
}
}
Контроллеры выглядят так:
public function startCheckout(Request $request, StripeCustomerService $customers)
{
$stripeCustomer = $customers->findOrCreate($request->user());
// ... собираем PaymentIntent / Checkout Session через $stripeCustomer->id
}
Колонка stripe_customer_id на users – одна миграция:
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_customer_id')->nullable()->unique()->after('email');
});
Эта колонка, stripe_subscription_id на той модели, у которой подписка, и таблица processed_webhooks – обычно вся схема. У Cashier её заметно больше.
Коллизия пространств имён с Eloquent-моделью Customer
Если в домене уже есть App\Models\Customer (типично для B2B-приложений, инвойсингов, маркетплейсов), то use Stripe\Customer; и локальный use App\Models\Customer; в одном файле одновременно не резолвятся. PHP падает на компиляции: Cannot use App\Models\Customer as Customer because the name is already in use. Более коварный случай – когда use всего один, а до второго класса добираются полным неймспейсом (\App\Models\Customer) в том же модуле: тогда instanceof и type-хинты молча расходятся между файлами, и баг всплывает только на ревью.
Решения два, оба уродливые, выбираем какое меньше режет глаз в конкретном месте:
// Вариант 1: алиас класса Stripe
use Stripe\Customer as StripeCustomer;
use App\Models\Customer;
public function syncFromStripe(Customer $customer, StripeCustomer $stripe): void
{
$customer->stripe_id = $stripe->id;
$customer->save();
}
// Вариант 2: алиас Eloquent-модели
use App\Models\Customer as CustomerModel;
use Stripe\Customer;
public function syncFromStripe(CustomerModel $customer, Customer $stripe): void
{
$customer->stripe_id = $stripe->id;
$customer->save();
}
Вариант 1 чаще оседает в проектах – в Laravel-приложении Eloquent-модели весят больше, чем классы SDK, и трение удобнее переложить на тот импорт, к которому реже лезешь. Главное – делать это консистентно, смешивать оба алиаса в разных файлах одного модуля опасно при рефакторингах.
Третий вариант – переименовать Eloquent-класс в Account, Client или Member – иногда оказывается правильным. Зависит от того, customer у вас – это доменное понятие или просто слово, доставшееся по наследству от ранней версии схемы.
Разовый платёж
Полный пайплайн PaymentIntent – создать, подтвердить, прогнать 3DS, проверить 'succeeded' === status – разобран в руководстве по Stripe-интеграции. В Laravel это тот же код внутри действия контроллера:
// routes/web.php
Route::post('/pay', [PaymentController::class, 'store']);
// app/Http/Controllers/PaymentController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\StripeClient;
class PaymentController extends Controller
{
public function __construct(private StripeClient $stripe) {}
public function store(Request $request)
{
$validated = $request->validate([
'amount_cents' => ['required', 'integer', 'min:50'],
'currency' => ['required', 'string', 'size:3'],
]);
$intent = $this->stripe->paymentIntents->create([
'amount' => $validated['amount_cents'],
'currency' => strtolower($validated['currency']),
'customer' => $request->user()->stripe_customer_id,
'automatic_payment_methods' => ['enabled' => true],
'metadata' => ['user_id' => (string) $request->user()->id],
]);
return response()->json(['client_secret' => $intent->client_secret]);
}
}
Фронт подтверждает PaymentIntent через Stripe.js с использованием client_secret, итоговый статус прилетает на бек событием payment_intent.succeeded. Не стоит ждать ответа HTTP на confirm-вызов, чтобы зафиксировать заказ – пользователи закрывают вкладку, сети рвутся, у Stripe.js случаются таймауты. Источник правды – webhook.
Подписка без Cashier
Полный жизненный цикл подписки – настройка Product/Price, default_incomplete, proration, Customer Portal – лежит в статье про Stripe Subscriptions в PHP. В Laravel это сервисный метод:
// app/Services/Billing/StripeSubscriptionService.php
namespace App\Services\Billing;
use App\Models\User;
use Stripe\StripeClient;
class StripeSubscriptionService
{
public function __construct(private StripeClient $stripe) {}
public function subscribe(User $user, string $priceId): array
{
$subscription = $this->stripe->subscriptions->create([
'customer' => $user->stripe_customer_id,
'items' => [['price' => $priceId]],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [
'save_default_payment_method' => 'on_subscription',
],
'expand' => ['latest_invoice.confirmation_secret'],
'metadata' => ['user_id' => (string) $user->id],
]);
$user->forceFill([
'stripe_subscription_id' => $subscription->id,
'stripe_subscription_status' => $subscription->status,
])->save();
return [
'subscription_id' => $subscription->id,
'client_secret' => $subscription->latest_invoice->confirmation_secret->client_secret,
];
}
}
На users лежат две колонки: stripe_subscription_id, чтобы подписку можно было ретривнуть, и stripe_subscription_status, чтобы проверки доступа не ходили за каждым разом в Stripe. Статус поддерживается свежим через webhook-и (customer.subscription.updated, customer.subscription.deleted); значению, которое протухло час назад, доверять нельзя.
Webhooks в Laravel
Верификация подписи, ловушка с raw body, идемпотентность и тестирование через CLI – всё это лежит в статье про Stripe webhooks. Laravel-специфики тут три вещи: исключение из CSRF, чтение сырого тела, и сама группа маршрутов.
Кладём webhook в отдельный контроллер, вне auth- и CSRF-миддлваров:
// routes/web.php
use App\Http\Controllers\Webhooks\StripeWebhookController;
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])
->name('stripe.webhook');
// app/Http/Controllers/Webhooks/StripeWebhookController.php
namespace App\Http\Controllers\Webhooks;
use App\Http\Controllers\Controller;
use App\Jobs\Stripe\DispatchStripeEvent;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature', '');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $signature, $secret);
} catch (SignatureVerificationException) {
abort(400, 'Invalid signature');
} catch (\UnexpectedValueException) {
abort(400, 'Invalid payload');
}
DispatchStripeEvent::dispatch($event->id)->onQueue('stripe');
return response('', 200);
}
}
$request->getContent() возвращает сырую строку. $request->all() и $request->json() уже декодировали JSON, и если их заново сериализовать – получится не та последовательность байт, которую подписал Stripe. Это самая частая причина SignatureVerificationException в Laravel-приложениях.
Исключаем путь webhook-а из CSRF. В Laravel 11+ это в bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
]);
})
В Laravel 10 и ниже – свойство $except на App\Http\Middleware\VerifyCsrfToken. Если этот шаг пропустить, каждый webhook прилетает 419 Page Expired ещё до того, как начинается ваш контроллер – Stripe ретраит три дня, а вы ничего не видите.
Класть маршрут в routes/api.php, чтобы обойти CSRF, – не нужно. К маршрутам API подключаются throttle:api и SubstituteBindings, они безвредны, но лишние, а исключение по пути – более явный сигнал намерения.
Ловушка очереди: PII в payload-ах job-ов
Это специфичный для связки Laravel и Stripe подводный камень, которого обычно нет ни в одном туториале. Вы диспатчите Job и пропихиваете Stripe\Event свойством:
// ТАК ДЕЛАТЬ НЕ НАДО
class DispatchStripeEvent implements ShouldQueue
{
public function __construct(public \Stripe\Event $event) {}
}
Laravel сериализует свойства конструктора в payload джоба – в Redis, в БД или куда у вас настроена очередь. Stripe\Event – жирный объект. К тому моменту, когда вы пихнули туда payment_intent.succeeded, внутри лежат email клиента, имя, billing_details.address, card.last4 и card.brand у метода оплаты, плюс вся метадата, которую вы привесили. Всё это теперь живёт на очереди, которую, скорее всего, никто не проектировал под хранение PII.
Правильно – передавать ID события и перечитывать объект внутри джоба:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Stripe\StripeClient;
class DispatchStripeEvent implements ShouldQueue
{
use Queueable;
public function __construct(public string $eventId) {}
public function handle(StripeClient $stripe): void
{
$event = $stripe->events->retrieve($this->eventId);
match ($event->type) {
'payment_intent.succeeded' => app(PaymentSucceededHandler::class)->handle($event),
'customer.subscription.updated' => app(SubscriptionUpdatedHandler::class)->handle($event),
'customer.subscription.deleted' => app(SubscriptionCancelledHandler::class)->handle($event),
'invoice.payment_failed' => app(InvoicePaymentFailedHandler::class)->handle($event),
default => null,
};
}
}
Это консолидированная форма, которую php artisan make:job генерит на Laravel 11+. Трейт Illuminate\Foundation\Queue\Queueable объединяет Dispatchable, InteractsWithQueue, Bus\Queueable и SerializesModels. На Laravel 10 и ниже его нет – импортируете четыре трейта по отдельности и перечисляете их в одном use, функционально то же самое.
Компромисс: плюс один API-вызов на каждый джоб. Плюсы: payload очереди – восьмисимвольная строка, PII остаётся в системе Stripe, джоб можно безопасно переиграть – если драйвер очереди переотправит его по таймауту, второй прогон вытащит свежий снимок, а не мутировавший объект, сериализованный час назад.
Альтернатива, если по причинам latency без события на джобе никак – гонять очередь на драйвере с таким же compliance-режимом, как у основной БД, и открытым текстом это задокументировать. “В нашем Redis лежат данные о брендах карт” – формулировка, которая всплывает на security-ревью поздно и переворачивает аудит.
Когда IncompletePayment у Cashier не срабатывает на 3DS
Даже если вы прошли по этому гайду, иногда оказываетесь у Cashier – потому что коллега начал до того, как было принято решение про прямой SDK. У Cashier есть одна острая грань, которую стоит знать, чтобы обойти на этапе сборки, а не отлаживать в два часа ночи.
Когда новая Subscription требует 3D Secure ('requires_action' === subscription.latest_invoice.payment_intent.status), Cashier бросает Laravel\Cashier\Exceptions\IncompletePayment. У исключения есть публичное свойство $payment – это Laravel\Cashier\Payment, обёртка над нижележащим PaymentIntent. Ловим, редиректим:
use Laravel\Cashier\Exceptions\IncompletePayment;
try {
$subscription = $user->newSubscription('default', $priceId)->create($paymentMethodId);
} catch (IncompletePayment $e) {
return redirect()->route(
'cashier.payment',
[$e->payment->id, 'redirect' => route('home')]
);
}
Что ловит людей врасплох (классический паттерн на Stack Overflow и в issue-трекере laravel/cashier-stripe): исключение бросается только внутри create() и swap(), но не при загрузке уже существующей подписки в состоянии incomplete. Если пользователь закрыл вкладку во время 3DS и вернулся позже, ваш код подгружает Cashier-подписку со stripe_status = 'incomplete', и ничего не бросается. Статус приходится проверять руками:
$sub = $user->subscription('default');
if ('incomplete' === $sub->stripe_status) {
$intent = $sub->latestPayment()?->asStripePaymentIntent();
if ($intent && 'requires_action' === $intent->status) {
return redirect()->route('cashier.payment', [
$intent->id,
'redirect' => route('home'),
]);
}
}
latestPayment() ходит в Stripe API, чтобы резолвнуть latest_invoice.payments – кешированной колонки в таблице Cashier-подписок нет, там только stripe_status, stripe_price, quantity, trial_ends_at, ends_at. PaymentIntent всегда в одном API-вызове.
Это же причина, по которой в прямом SDK-подходе мы явно указываем payment_behavior: 'default_incomplete' и возвращаем confirmation_secret.client_secret во фронт: SCA-хендофф виден в собственном коде, а не закопан в семантике исключения, зависящей от того, какой метод Cashier вы вызвали.
spatie/laravel-stripe-webhooks: средний путь
Если связка контроллер-плюс-джоб-плюс-обработчик-на-каждое-событие начинает выглядеть как перепайка одной и той же инфраструктуры из проекта в проект, spatie/laravel-stripe-webhooks – единственный Cashier-смежный пакет, который стоит взять. Он закрывает верификацию подписи, исключение из CSRF, чтение сырого тела и диспатчинг Job-ов; вам остаётся написать хендлер-на-событие и зарегистрировать их в config/stripe-webhooks.php:
// config/stripe-webhooks.php
return [
'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
'jobs' => [
'payment_intent_succeeded' => \App\Jobs\Stripe\HandlePaymentSucceeded::class,
'customer_subscription_updated' => \App\Jobs\Stripe\HandleSubscriptionUpdated::class,
],
];
Под капотом пакет использует тот же stripe/stripe-php, так что вы всё ещё сидите на официальном SDK – это не альтернативная обёртка, а инфраструктура для диспетчеризации. Единственная задержка – после мажорного релиза stripe-php нужно подождать, пока Spatie тегнет совместимую версию. Для большинства проектов это нормально. Если живёте на фронтире SDK-релизов (новые фичи в день выхода) – проще собрать свой диспетчер, чем пинить транзитивную версию.
Биллинговый UI из Cashier, PDF-инвойсы и API подписок этот пакет вам не даст. Это чистая webhook-инфраструктура – ровно то, за чем “прямой SDK”-проекты обычно идут к пакету.
Тестирование: подмена синглтона
Поскольку StripeClient забинджен синглтоном в контейнере, feature-тесты могут подменить его заглушкой или моком:
use Tests\TestCase;
use Stripe\StripeClient;
use Stripe\Service\PaymentIntentService;
use Mockery;
class CheckoutTest extends TestCase
{
public function test_checkout_creates_a_payment_intent(): void
{
// makePartial() сохраняет у StripeClient __get-фабрику для любого сервиса,
// который мы не подменяем; переопределяем только paymentIntents.
$stripe = Mockery::mock(StripeClient::class)->makePartial();
$paymentIntents = Mockery::mock(PaymentIntentService::class);
$paymentIntents->shouldReceive('create')
->once()
->andReturn((object) ['client_secret' => 'pi_test_secret']);
$stripe->paymentIntents = $paymentIntents;
$this->app->instance(StripeClient::class, $stripe);
$this->actingAs($this->user)
->postJson('/pay', ['amount_cents' => 500, 'currency' => 'usd'])
->assertOk()
->assertJson(['client_secret' => 'pi_test_secret']);
}
}
$this->app->instance() подменяет синглтон на время теста. makePartial() на моке StripeClient – ключевой момент: настоящий StripeClient резолвит paymentIntents, customers, subscriptions и остальные сервисы не как публичные свойства, а через __get-фабрику из \Stripe\Service\AbstractServiceFactory. Полный мок Mockery перехватит всё, и у неподменённых сервисов окажется null. Partial оставляет фабрику живой и разрешает переопределить только тот сервис, который нужен.
Без биндинга в синглтон пришлось бы мокать статические вызовы \Stripe\PaymentIntent – это возможно через alias-моки Mockery, но хрупко и плохо изолируется между тестами.
Для интеграционных тестов, которые ходят в настоящий Stripe (в test-режиме), мок не нужен – пусть контейнер отдаёт реальный StripeClient. Для сценариев отказа используйте pm_card_chargeDeclined (или номер карты 4000 0000 0000 0002 через фронт), а созданные объекты подчищайте в tearDown() или помечайте тестовым метадата-тегом.
Laravel 10 и ниже
Часть сниппетов выше – Laravel-11+ специфика, потому что в Laravel 11 убрали Http/Kernel.php и конфигурацию middleware перенесли в bootstrap/app.php. На Laravel 10 и ниже:
- CSRF-исключение живёт на
App\Http\Middleware\VerifyCsrfToken::$except, а не вbootstrap/app.php. - Регистрация сервис-провайдеров идёт в массив
providersвconfig/app.php, не вbootstrap/providers.php. - Тело запроса –
$request->getContent()идентичен, метод тот же. - Трейты джоба –
Illuminate\Foundation\Queue\Queueableотсутствует, импортируются четыре трейта (Dispatchable, InteractsWithQueue, Queueable, SerializesModels) по отдельности, как их и генерируетphp artisan make:jobна Laravel 10.x.
Cashier перешёл на PaymentIntent/SCA-флоу ещё в v10 (2019); версии Cashier до девятой – последние из Charges-эпохи. Туториалы старше 2019 года, где до сих пор показан паттерн $user->charge(100, $token), не проходят SCA в ЕС и Великобритании и без переработок не запустятся против актуальной Cashier.
Типичные проблемы
419 Page Expired на webhook-е. Исключение из CSRF не применилось. На Laravel 11+ проверяем bootstrap/app.php. После правки на продакшене, где кешируются маршруты и конфиг, гоним php artisan route:cache && php artisan config:cache.
SignatureVerificationException на webhook-е. Сырое тело где-то переписано. Проверяем, что в контроллере именно $request->getContent(), а не $request->all(). Заодно стоит глянуть, не модифицирует ли payload какой-нибудь middleware (сжатие, парсинг тела, security-сканер перед Laravel).
No API key provided на первом Stripe-вызове после деплоя. Запустили php artisan config:cache до того, как новое .env легло на диск, или обновили ключ и забыли пересобрать кеш. Делаем php artisan config:clear, подтверждаем через php artisan tinker и config('services.stripe.secret'), затем пересобираем кеш.
Cannot declare class Customer. Коллизия с Eloquent-моделью. Алиасим один из классов (см. раздел про коллизию).
IncompletePayment у Cashier не бросается при загрузке существующей подписки. По дизайну. Исключение срабатывает только внутри create() / swap(). Проверяем 'incomplete' === $sub->stripe_status самостоятельно на загрузке и редиректим на 3DS.
Драйвер очереди хранит email клиентов в открытом виде. Job сериализует целиком Stripe\Event. Переписываем на диспатч с одним только ID события и рефетч внутри handle().
Что дальше
- Stripe webhooks в PHP – фреймворк-независимое руководство по верификации подписи, идемпотентности и локальному тестированию через Stripe CLI. Секция выше про webhook-и в Laravel опирается на него.
- Stripe Subscriptions в PHP – полный жизненный цикл с
default_incomplete, proration, триалами и Customer Portal. Laravel-сервис для подписок – тонкая обёртка над этим API. - Stripe-интеграция в PHP – первый платёж, состояния PaymentIntent, 3DS-хендофф. Laravel
PaymentController– тот же флоу внутри Laravel-маршрута. - Cashier comparison notes в самой документации Laravel – прочитать стоит, даже если Cashier не используете. Там описано, что делает официальная обёртка от фреймворка – полезный контекст на случай, когда придётся принять решение в пользу одной из её фич позже.
Нашли неточность на этой странице?
Сообщить об ошибке