phpguzzle.org
enrudees
Stripe – Docs

Cashier frames the choice. Most Laravel-plus-Stripe tutorials reach for laravel/cashier-stripe because it is officially maintained and the billing screens are already wired – trials, invoices, the Billable trait on User. The cost is a second set of migrations, conventions about where billing state lives, and a shim that has to ship a release after every stripe-php major. For a SaaS on a fast track with plain subscriptions, Cashier is the right call. For a multi-tenant marketplace, a non-subscription product, or a team that already has its own opinions about where stripe_customer_id should live, it is in the way.

This guide takes the opposite route. Install stripe/stripe-php directly, bind \Stripe\StripeClient as a singleton in a service provider, keep controllers lean behind a thin service class, and handle webhooks in plain Laravel. Cashier will show up twice, both times honestly – once in the comparison up top, once when its one well-known 3DS rough edge is worth naming so you can ship around it.

The examples target Laravel 11 and 12 (middleware in bootstrap/app.php) and the current Stripe API version (2025-03-31 and later). Where Laravel 10 differs, there is a note at the bottom.

Does Laravel have a Stripe package?

Two, really. laravel/cashier-stripe is the official one and handles subscriptions end-to-end: User::newSubscription()->create($token), invoice PDFs, coupon redemption, the works. spatie/laravel-stripe-webhooks is narrower – it only handles the inbound side of webhooks, giving you a clean Job-per-event dispatcher on top of stripe/stripe-php. Outside of those, there is stripe/stripe-php itself, which is the Stripe-maintained SDK and what Cashier uses internally.

The decision tree is shorter than the packages suggest:

You want…Pick
Subscription SaaS with standard plans, trials, invoicesCashier
One-off payments, marketplaces, usage-billing, connectstripe/stripe-php directly
Webhook infrastructure only, rest of Stripe code your ownspatie/laravel-stripe-webhooks
Full control over schema and flow, minimal framework magicstripe/stripe-php directly (this guide)

Cashier is not a thin wrapper. It adds subscriptions, subscription_items, and a few other tables to your database, assumes the billable record is your User, exposes Stripe objects through its own fluent API ($user->subscription('default')->cancel()), and expects you to operate in its vocabulary. When your billing story fits that vocabulary, it saves weeks. When it does not – say, you charge per-seat across multiple teams the user belongs to, or you have already modelled Stripe entities in your domain – Cashier becomes code you fight.

The rest of this article assumes you have made the “direct SDK” call and want the Laravel-specific plumbing: service provider, webhook route wiring, queue hygiene, testability.

Installation and configuration

Add the SDK to composer.json:

composer require stripe/stripe-php

Stripe PHP itself is permissive on version (current master declares PHP 7.2+); the effective floor is whatever the framework imposes. Laravel 11 and 12 require PHP 8.2+, and Cashier tracks whichever Laravel version it ships against.

Put the keys in .env:

STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

And expose them through config/services.php, not as constants or static calls:

// config/services.php
return [
    // ...
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
        'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
        'api_version' => '2025-03-31',
    ],
];

Two reasons this matters. First, env() outside a config file returns null after php artisan config:cache has run, because Laravel no longer loads .env at boot. If you read env('STRIPE_SECRET') in a controller, it works on your laptop and fails in production the first time someone runs config:cache. Second, config('services.stripe.secret') is mockable per-test via Config::set(...), while static calls and env() reads are not.

After changing .env on a cached environment, remember to rerun the cache command:

php artisan config:cache

Skipping the refresh is the single most common “the key rotated and nothing is reading it” bug in Laravel Stripe deploys.

The service provider

The static \Stripe\Stripe::setApiKey() pattern still works, but it is global mutable state and it makes multi-tenant setups (different Stripe accounts per organisation) awkward. Bind \Stripe\StripeClient as a singleton instead:

// 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'),
            ]);
        });
    }
}

Register it in bootstrap/providers.php (Laravel 11+) or config/app.php (Laravel 10 and earlier):

// bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\StripeServiceProvider::class,
];

Now controllers and services can type-hint StripeClient and Laravel’s container hands them the configured instance:

public function __construct(private StripeClient $stripe) {}

// inside a method:
$customer = $this->stripe->customers->create([
    'email' => $user->email,
    'metadata' => ['user_id' => (string) $user->id],
]);

The resource calls (customers->create, paymentIntents->retrieve, subscriptions->update, …) come off the client instance. The static API – \Stripe\Customer::create(...) – is still valid and does the same thing, but the instance form is easier to swap in tests, cleaner with multiple accounts, and what Stripe recommends for new code.

A thin service class

Routing everything through a service class keeps controllers readable and gives you a seam for logging, metrics, and retries. Start narrow:

// 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;
    }
}

Controllers then look like this:

public function startCheckout(Request $request, StripeCustomerService $customers)
{
    $stripeCustomer = $customers->findOrCreate($request->user());
    // ... build PaymentIntent / Checkout Session using $stripeCustomer->id
}

The stripe_customer_id column on users is one migration:

Schema::table('users', function (Blueprint $table) {
    $table->string('stripe_customer_id')->nullable()->unique()->after('email');
});

That column, stripe_subscription_id on whichever model owns the subscription, and a processed_webhooks table are usually the whole schema. Cashier adds significantly more.

The namespace collision with Eloquent’s Customer

If your domain already has an App\Models\Customer (common in B2B apps, invoicing tools, marketplaces), use Stripe\Customer; and a local use App\Models\Customer; in the same file will not both resolve. PHP hard-errors at compile time: Cannot use App\Models\Customer as Customer because the name is already in use. The subtler failure mode is having only one use line and referring to the other class through a fully-qualified name (\App\Models\Customer) in the same module – then instanceof checks and type hints silently disagree across files and the bug only surfaces in code review.

Two fixes, both ugly, pick whichever is uglier locally:

// Option 1: alias Stripe's class
use Stripe\Customer as StripeCustomer;
use App\Models\Customer;

public function syncFromStripe(Customer $customer, StripeCustomer $stripe): void
{
    $customer->stripe_id = $stripe->id;
    $customer->save();
}
// Option 2: alias the Eloquent model
use App\Models\Customer as CustomerModel;
use Stripe\Customer;

public function syncFromStripe(CustomerModel $customer, Customer $stripe): void
{
    $customer->stripe_id = $stripe->id;
    $customer->save();
}

Option 1 is what ends up in most codebases – Eloquent models carry more weight in a Laravel app than SDK classes, so you accept the friction on the less-touched import. Do it consistently; mixing both aliases in different files of the same module is a correctness hazard during refactors.

A third option – renaming your Eloquent class to Account or Client or Member – is sometimes the right call. It depends on whether “customer” is a domain concept in your app or just an inherited word from an earlier data model.

Accepting a one-off payment

For the full PaymentIntent flow – creating, confirming, handling 3DS, checking 'succeeded' === status – the plumbing is covered in the Stripe payment integration guide. The Laravel version is the same code inside a controller action:

// 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]);
    }
}

The frontend confirms the PaymentIntent with Stripe.js using the client_secret; the status lands on your server via the payment_intent.succeeded webhook. Do not wait for the HTTP response from the confirm call to fulfil the order – users close tabs, networks drop, Stripe.js timeouts happen. The webhook is the source of truth.

Creating a subscription without Cashier

Full subscription lifecycle – Product/Price setup, default_incomplete, proration, Customer Portal – is covered in Stripe subscriptions in PHP. In Laravel it lives in a service method:

// 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,
        ];
    }
}

Two pieces of state live on users: stripe_subscription_id so you can retrieve it, and stripe_subscription_status so access checks do not have to round-trip to Stripe. Keep the status fresh via webhooks (customer.subscription.updated, customer.subscription.deleted); do not trust a value that has drifted for an hour.

Webhooks in Laravel

The signature verification, raw-body trap, idempotency, and CLI testing are all covered in the Stripe webhooks guide. The Laravel-specific pieces are three: the CSRF exemption, reading the raw body, and the route group.

Put the webhook on a dedicated controller, outside any auth or CSRF middleware:

// 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 (\Stripe\Exception\UnexpectedValueException) {
            abort(400, 'Invalid payload');
        }

        DispatchStripeEvent::dispatch($event->id)->onQueue('stripe');

        return response('', 200);
    }
}

$request->getContent() returns the raw string. $request->all() or $request->json() will have been JSON-decoded and re-encoding them will not match the byte sequence Stripe signed. This is the single most common reason constructEvent throws SignatureVerificationException in a Laravel app.

Exclude the webhook path from CSRF. In Laravel 11+ that goes in bootstrap/app.php:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'stripe/webhook',
    ]);
})

In Laravel 10 and earlier it is the $except property on App\Http\Middleware\VerifyCsrfToken. Skip this and every webhook comes back as 419 Page Expired before your controller runs – Stripe retries for three days and you see nothing.

Do not put the route in routes/api.php to avoid CSRF. The API middleware group adds throttle:api and SubstituteBindings, which are harmless but unnecessary, and the route-path CSRF exclude is the clearer signal of intent.

The queue trap: PII in job payloads

This is the gotcha that is specific to Laravel-plus-Stripe and missing from most tutorials. You dispatch a Job with the Stripe\Event as a property:

// DON'T DO THIS
class DispatchStripeEvent implements ShouldQueue
{
    public function __construct(public \Stripe\Event $event) {}
}

Laravel serializes constructor properties into the job payload – in Redis, the database, or wherever your queue driver stores things. Stripe\Event is a rich object. By the time you push a payment_intent.succeeded event, the payload includes the customer’s email, name, billing_details.address, the payment method’s card.last4 and card.brand, and any metadata you attached. All of that is now sitting on a queue connection that was probably not designed as a PII store.

Pass the event ID instead and refetch inside the Job:

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,
        };
    }
}

This is the consolidated form php artisan make:job produces on Laravel 11+. The Illuminate\Foundation\Queue\Queueable trait bundles Dispatchable, InteractsWithQueue, the Bus\Queueable trait, and SerializesModels. On Laravel 10 and earlier, swap it for the four separate use statements and the four-trait use declaration on the class – functionally identical.

Trade-off: one extra API call per job. Upside: the queue payload is an eight-character string, the PII stays in Stripe’s system, and the job is reentrant – if your queue driver redelivers on timeout, the second run pulls a fresh snapshot rather than an object that may have mutated since it was serialized.

The alternative, if you must keep the event on the job for latency reasons, is to run your queue on a driver with the same compliance posture as your primary database and document that clearly. “Our Redis has credit card brand data” is the kind of thing that catches a security review late and reshapes an audit.

When Cashier’s Incomplete exception fails on 3DS

Even if you followed this guide, sometimes you end up calling Cashier because a teammate started it before the direct-SDK decision was made. One Cashier rough edge is worth knowing so you ship around it rather than debug it at 2 a.m.

When a new Subscription requires 3D Secure ('requires_action' === subscription.latest_invoice.payment_intent.status), Cashier throws Laravel\Cashier\Exceptions\IncompletePayment. The exception carries a public $payment property – a Laravel\Cashier\Payment that wraps the underlying PaymentIntent. You catch it and redirect:

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')]
    );
}

What surprises people (the classic Stack Overflow and laravel/cashier-stripe GitHub issue pattern) is that the exception is only raised inside create() and swap(), not when you retrieve an existing Subscription that landed in the incomplete state. If a user closed their tab during 3DS and comes back later, your code loads a Cashier subscription whose stripe_status is incomplete, and nothing throws. You have to check the status yourself:

$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() hits the Stripe API to resolve latest_invoice.payments – there is no cached column on the Cashier subscription table, just stripe_status, stripe_price, quantity, trial_ends_at, ends_at. The PaymentIntent is always one API call away.

This is also why the direct-SDK approach in this article is explicit about payment_behavior: 'default_incomplete' and returning confirmation_secret.client_secret to the frontend – the SCA handoff is visible in your own code instead of buried behind an exception whose semantics depend on which Cashier method you called.

spatie/laravel-stripe-webhooks: the middle path

If the webhook controller plus Job plus handler-per-event pattern feels like you are rebuilding the same infrastructure every project, spatie/laravel-stripe-webhooks is the one Cashier-adjacent package worth reaching for. It handles the signature verification, CSRF exemption, raw-body read, and Job dispatching; you write the handler-per-event-type and register them in 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,
    ],
];

The package uses stripe/stripe-php under the hood, so you are still on the Stripe-maintained SDK – this is not an alternative wrapper, it is dispatcher infrastructure. The only lag is that after a stripe-php major release, you wait for Spatie to tag a compatible version. For most apps that is fine. If you live on the edge of SDK releases (new features on day one), rolling your own dispatcher is simpler than pinning a transitive version.

It does not give you Cashier’s billing UI, invoice PDFs, or the subscription API. It is webhooks only, which is exactly what most “direct SDK” projects want a package for.

Testing: swapping the singleton

Because StripeClient is bound as a singleton in the container, feature tests can swap it with a stub or a mock:

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() keeps StripeClient's __get factory for any service
        // we do not stub; we override only 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']);
    }
}

The $this->app->instance() call replaces the singleton for the duration of the test. makePartial() on the StripeClient mock matters: the real StripeClient resolves paymentIntents, customers, subscriptions, etc. through a __get factory in \Stripe\Service\AbstractServiceFactory, not as public properties. A full Mockery mock intercepts everything and the unmocked services would come back null. Partial keeps the factory live and lets you override only the service you care about.

Without the singleton binding, you would be mocking static calls on \Stripe\PaymentIntent – possible with tools like Mockery’s alias mocks, but fragile and hard to isolate.

For integration tests that hit real Stripe (in test mode), skip the mock entirely and let the container hand out the real StripeClient. Use pm_card_chargeDeclined (or card number 4000 0000 0000 0002 through the frontend) for decline paths, and clean up created objects in tearDown() or scope them under a test-only metadata tag.

Laravel 10 and earlier

A handful of the snippets above are Laravel 11+ specific because Laravel 11 removed Http/Kernel.php and moved middleware configuration into bootstrap/app.php. On Laravel 10 and earlier:

  • CSRF exclusion lives on App\Http\Middleware\VerifyCsrfToken::$except, not in bootstrap/app.php.
  • Service provider registration goes in the providers array of config/app.php, not bootstrap/providers.php.
  • Request body$request->getContent() is identical, same method.
  • Job traitsIlluminate\Foundation\Queue\Queueable does not exist; use the four separate traits (Dispatchable, InteractsWithQueue, Queueable, SerializesModels) and import them individually, as php artisan make:job emits on Laravel 10.x.

Cashier has been on the PaymentIntent/SCA flow since v10 (2019); Cashier v9 and earlier were the last Charges-era releases. Tutorials older than 2019 that still show $user->charge(100, $token) patterns do not pass SCA in the EU and UK and will not run against a modern Cashier install unchanged.

Common problems

419 Page Expired on the webhook. The CSRF exclude did not take. On Laravel 11+ check bootstrap/app.php. After editing, run php artisan route:cache && php artisan config:cache if your production environment caches them.

SignatureVerificationException on the webhook. The raw body is being modified. Make sure you are using $request->getContent(), not $request->all(). Also check that no middleware (compression, body-parsing, security scanners in front of Laravel) is rewriting the payload between the TLS terminator and your controller.

No API key provided on the first Stripe call after a deploy. You ran php artisan config:cache before the deploy wrote the new .env, or you wrote the new key and forgot to rerun the cache. php artisan config:clear once, confirm with php artisan tinker and config('services.stripe.secret'), then re-cache.

Cannot declare class Customer. Namespace collision with your Eloquent model. Alias one of them (see the collision section).

Cashier IncompletePayment not thrown when loading an existing subscription. By design. The exception only fires inside create() / swap(). Check 'incomplete' === $sub->stripe_status yourself on load and redirect to 3DS if so.

Queue driver storing customer emails in plain text. The Job is serializing the whole Stripe\Event. Rewrite to dispatch with the event ID only; refetch inside handle().

What next

  • Stripe webhooks in PHP – the framework-agnostic guide to signature verification, idempotency, and local testing with the Stripe CLI. Everything in the Laravel webhook section above leans on it.
  • Stripe subscriptions in PHP – the full lifecycle including default_incomplete, proration, trials, and the Customer Portal. The Laravel subscription service class is a thin wrapper over that API.
  • Stripe payment integration in PHP – first-payment flow, PaymentIntent states, 3DS handoff. The Laravel PaymentController is the same flow inside a Laravel route.
  • Stripe’s own Cashier comparison notes – read the Cashier docs even if you are not using it. They describe what the framework-official wrapper does, which is useful context when you decide you need one of those features later.

Spotted something inaccurate on this page?

Report an error