phpguzzle.org
enrudees
Stripe – Документация

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, Connectstripe/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 не используете. Там описано, что делает официальная обёртка от фреймворка – полезный контекст на случай, когда придётся принять решение в пользу одной из её фич позже.

Нашли неточность на этой странице?

Сообщить об ошибке