phpguzzle.org
enrudees
Stripe – Doku

Stripe in Laravel ohne Cashier

Cashier definiert die Entscheidung. Die meisten Laravel-Stripe-Tutorials greifen zu laravel/cashier-stripe, weil es offiziell gepflegt wird und die Billing-Screens fertig verdrahtet sind – Trials, Invoices, das Billable-Trait auf User. Der Preis: ein zusätzlicher Satz Migrationen, Konventionen darüber, wo Billing-State lebt, und eine Zwischenschicht, die nach jedem stripe-php-Major ein Release nachziehen muss. Für ein SaaS mit Standard-Abos ist Cashier die richtige Wahl. Für einen Multi-Tenant-Marketplace, ein Produkt ohne Abos oder ein Team, das eigene Vorstellungen hat, wo stripe_customer_id liegen soll, steht Cashier im Weg.

Dieser Artikel nimmt den anderen Weg. stripe/stripe-php direkt installieren, \Stripe\StripeClient als Singleton im Service Provider binden, Controller schlank hinter einer Service-Klasse halten und Webhooks in reinem Laravel verarbeiten.

Die Beispiele zielen auf Laravel 11 und 12 (Middleware in bootstrap/app.php) und die aktuelle Stripe API (2025-03-31 und neuer). Wo Laravel 10 abweicht, steht ein Hinweis am Ende.

Cashier oder direkt?

AnforderungEmpfehlung
Abo-SaaS mit Standard-Plänen, Trials, InvoicesCashier
Einmalzahlungen, Marketplace, Usage-Billing, Connectstripe/stripe-php direkt
Nur Webhook-Infrastruktur, Rest selbstspatie/laravel-stripe-webhooks
Volle Kontrolle über Schema und Flowstripe/stripe-php direkt (dieser Artikel)

Cashier ist kein dünner Wrapper. Es fügt subscriptions, subscription_items und weitere Tabellen zur Datenbank hinzu, geht davon aus, dass das Billable-Model User ist, exponiert Stripe-Objekte über eine eigene Fluent API und erwartet, dass man in seinem Vokabular arbeitet. Wenn das Billing-Szenario dazu passt, spart es Wochen. Wenn nicht – etwa per-Seat-Abrechnung über mehrere Teams oder ein bereits modelliertes Stripe-Schema in der eigenen Domain – wird Cashier zu Code, gegen den man kämpft.

Installation und Konfiguration

SDK zu composer.json hinzufügen:

composer require stripe/stripe-php

Schlüssel in .env:

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

Und über config/services.php verfügbar machen, nicht als Konstanten oder statische Aufrufe:

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

Zwei Gründe. Erstens: env() außerhalb einer Config-Datei gibt null zurück, nachdem php artisan config:cache gelaufen ist, weil Laravel dann .env nicht mehr lädt. env('STRIPE_SECRET') in einem Controller funktioniert lokal, scheitert aber in Produktion nach dem ersten config:cache. Zweitens: config('services.stripe.secret') ist per Test mockbar über Config::set(...), env()-Aufrufe nicht.

Nach .env-Änderungen auf einem gecachten Environment:

php artisan config:cache

Den Cache-Refresh zu vergessen ist der häufigste “Key rotiert, nichts liest ihn”-Bug bei Laravel-Stripe-Deploys.

Service Provider

Die statische \Stripe\Stripe::setApiKey()-Methode funktioniert, ist aber globaler mutabler State und macht Multi-Tenant-Setups (verschiedene Stripe-Konten pro Organisation) umständlich. Stattdessen \Stripe\StripeClient als Singleton binden:

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

Registrieren in bootstrap/providers.php (Laravel 11+) oder config/app.php (Laravel 10):

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

Controller und Services können jetzt StripeClient als Dependency Injection nutzen:

public function __construct(private StripeClient $stripe) {}

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

Service-Klasse

Alles durch eine Service-Klasse zu routen hält Controller lesbar und gibt einen Ansatzpunkt für Logging, Metriken und Retries:

// app/Services/Billing/StripeCustomerService.php
namespace App\Services\Billing;

use App\Models\User;
use Stripe\StripeClient;
use Stripe\Customer as StripeCustomer;
use Stripe\Exception\InvalidRequestException;

class StripeCustomerService
{
    public function __construct(private StripeClient $stripe) {}

    public function findOrCreate(User $user): StripeCustomer
    {
        if ($user->stripe_customer_id) {
            try {
                return $this->stripe->customers->retrieve($user->stripe_customer_id);
            } catch (InvalidRequestException) {
                // Customer wurde in Stripe gelöscht (typisch nach Test/Live-Verwechslung).
                // Fall durch und erstelle einen neuen.
            }
        }

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

Die stripe_customer_id-Spalte auf users ist eine Migration:

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

Diese Spalte, stripe_subscription_id auf dem Modell, das das Abo besitzt, und eine processed_webhooks-Tabelle sind normalerweise das gesamte Schema. Cashier fügt deutlich mehr hinzu.

Namespace-Kollision mit Eloquents Customer

Wenn die eigene Domain schon ein App\Models\Customer hat (häufig in B2B-Apps, Invoicing-Tools, Marketplaces), kollidiert use Stripe\Customer; mit use App\Models\Customer; im selben File. PHP gibt einen Compile-Fehler: Cannot use App\Models\Customer as Customer because the name is already in use.

Zwei Lösungen:

// Option 1: Stripe-Klasse aliasieren (üblicher)
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: Eloquent-Modell aliasieren
use App\Models\Customer as CustomerModel;
use Stripe\Customer;

Option 1 findet sich in den meisten Codebases – Eloquent-Modelle haben mehr Gewicht in einer Laravel-App als SDK-Klassen. Konsistent durchziehen; in verschiedenen Files desselben Moduls beide Aliase zu mischen, erzeugt Fehler bei Refactorings.

Einmalzahlung

Für den vollständigen PaymentIntent-Flow – Erstellen, Bestätigen, 3DS, Status prüfen – deckt der Payment-Integration-Artikel die Details ab. In Laravel lebt es in einer Controller-Action:

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

Das Frontend bestätigt den PaymentIntent mit Stripe.js über den client_secret; der finale Status kommt per payment_intent.succeeded-Webhook. Nicht auf die HTTP-Response vom Confirm-Aufruf warten, um die Bestellung auszuliefern – Nutzer schließen Tabs, Netzwerke brechen ab. Der Webhook ist die Wahrheit.

Abo ohne Cashier

Der komplette Subscription-Lifecycle – Product/Price-Setup, default_incomplete, Proration, Customer Portal – steht im Subscriptions-Artikel. In Laravel lebt es in einer Service-Methode:

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

Zwei Felder auf users: stripe_subscription_id zum Abrufen, stripe_subscription_status damit Zugriffsprüfungen keinen Roundtrip zu Stripe machen müssen. Status per Webhooks (customer.subscription.updated, customer.subscription.deleted) aktuell halten.

Webhooks in Laravel

Signatur-Verifizierung, Raw-Body-Erfassung, Idempotenz und CLI-Tests deckt der Webhook-Artikel ab. Die Laravel-spezifischen Teile: CSRF-Ausnahme, Raw Body lesen, Route-Gruppe.

Den Webhook auf einen eigenen Controller legen, außerhalb von Auth- und CSRF-Middleware:

// 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() gibt den rohen String zurück. $request->all() oder $request->json() sind JSON-decoded, und re-encoding matcht nicht die Byte-Folge, die Stripe signiert hat. Das ist der häufigste Grund für SignatureVerificationException in Laravel-Apps.

Webhook-Pfad von CSRF ausschließen. In Laravel 11+:

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

In Laravel 10 und früher die $except-Property auf App\Http\Middleware\VerifyCsrfToken. Ohne diese Ausnahme gibt jeder Webhook 419 Page Expired zurück, bevor der Controller läuft.

Die Route selbst muss zum CSRF-Ausschluss passen – der Pfad ist exakt gemeint, ohne führenden Slash:

// routes/web.php
use App\Http\Controllers\WebhookController;

Route::post('/stripe/webhook', [WebhookController::class, 'handle']);

Wer die Route unter /api/stripe/webhook oder webhooks/stripe registriert, muss den Eintrag im except-Array entsprechend anpassen, sonst läuft der CSRF-Filter trotzdem.

Die Queue-Falle: PII im Job-Payload

Ein Gotcha, das spezifisch für Laravel plus Stripe ist. Man dispatcht einen Job mit dem Stripe\Event als Property:

// SO NICHT
class DispatchStripeEvent implements ShouldQueue
{
    public function __construct(public \Stripe\Event $event) {}
}

Laravel serialisiert Constructor-Properties in den Job-Payload – in Redis, der Datenbank oder dem jeweiligen Queue-Driver. Stripe\Event ist ein reichhaltiges Objekt. Bei einem payment_intent.succeeded enthält der Payload E-Mail, Name, billing_details.address, card.last4, card.brand und jede angehängte Metadata des Kunden. Das alles liegt dann auf einer Queue-Verbindung, die wahrscheinlich nicht als PII-Speicher konzipiert war.

Stattdessen nur die Event-ID dispatchen und im Job per API neu laden:

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

Trade-off: ein zusätzlicher API-Aufruf pro Job. Vorteil: der Queue-Payload ist ein kurzer String, die PII bleibt bei Stripe, und der Job ist reentrant – bei Redelivery durch den Queue-Driver holt der zweite Lauf einen aktuellen Snapshot.

Weil der Dispatch mit ->onQueue('stripe') einen benannten Queue-Namen setzt, muss der Worker diesen Namen kennen. Sonst stapeln sich die Jobs unbemerkt:

php artisan queue:work --queue=stripe,default

Alternative: das ->onQueue(...) weglassen und den Default-Queue verwenden – dann reicht der normale queue:work ohne Flag.

Cashier und die 3DS-Incomplete-Lücke

Auch bei der direkten SDK-Nutzung lohnt sich ein Blick auf Cashiers bekannten Stolperstein bei 3D Secure. Wenn eine neue Subscription 3DS verlangt, wirft Cashier Laravel\Cashier\Exceptions\IncompletePayment:

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

Was überrascht: die Exception wird nur in create() und swap() geworfen, nicht beim Laden einer bestehenden Subscription im Status incomplete. Wenn ein Nutzer den Tab während 3DS geschlossen hat und zurückkehrt, lädt der Code eine Cashier-Subscription mit stripe_status = incomplete, und nichts wirft. Den Status manuell prüfen:

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

Deshalb macht der Direct-SDK-Ansatz in diesem Artikel payment_behavior: 'default_incomplete' und die Rückgabe von confirmation_secret.client_secret ans Frontend explizit – der SCA-Handoff ist im eigenen Code sichtbar, statt hinter einer Exception versteckt.

spatie/laravel-stripe-webhooks

Wer das Muster “Webhook-Controller + Job + Handler-per-Event” nicht jedes Projekt neu aufbauen will: spatie/laravel-stripe-webhooks übernimmt Signatur-Verifizierung, CSRF-Ausnahme, Raw-Body-Read und Job-Dispatching. Man schreibt den Handler pro Event-Typ und registriert ihn 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,
    ],
];

Das Paket nutzt stripe/stripe-php intern – es ist kein alternativer Wrapper, sondern Dispatcher-Infrastruktur. Der einzige Nachteil: nach einem stripe-php-Major-Release wartet man auf Spaties kompatible Version. Für die meisten Apps kein Problem. Wer neue SDK-Features am Tag der Veröffentlichung braucht, baut den Dispatcher besser selbst.

Testen: Singleton tauschen

Weil StripeClient als Singleton im Container gebunden ist, können Feature-Tests ihn durch einen Stub oder Mock ersetzen:

use Tests\TestCase;
use Stripe\StripeClient;
use Stripe\PaymentIntent;
use Stripe\Service\PaymentIntentService;
use Mockery;

class CheckoutTest extends TestCase
{
    public function test_checkout_creates_a_payment_intent(): void
    {
        $stripe = Mockery::mock(StripeClient::class)->makePartial();

        $paymentIntents = Mockery::mock(PaymentIntentService::class);
        $paymentIntents->shouldReceive('create')
            ->once()
            ->andReturn(PaymentIntent::constructFrom([
                'id' => 'pi_test_123',
                'client_secret' => 'pi_test_secret',
                'status' => 'requires_confirmation',
                'amount' => 500,
                'currency' => 'usd',
            ]));

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

PaymentIntent::constructFrom() ist der Helper, mit dem das SDK selbst Antworten deserialisiert. Der zurückgegebene Stub verhält sich also wie ein echter PaymentIntent – auch beim späteren Zugriff auf ->status oder ->amount. Mit (object) [...] würde der Controller-Code $intent->status als null sehen und still ein falsches Ergebnis liefern.

$this->app->instance() ersetzt das Singleton für die Dauer des Tests. makePartial() auf dem StripeClient-Mock ist relevant: der echte StripeClient löst paymentIntents, customers, subscriptions über eine __get-Factory in AbstractServiceFactory auf. Ein vollständiger Mock fängt alles ab und nicht gemockte Services geben null zurück. Partial lässt die Factory aktiv und erlaubt, nur den einen Service zu überschreiben.

Laravel 10 und früher

Einige Snippets sind Laravel-11+-spezifisch. Auf Laravel 10:

  • CSRF-Ausnahme lebt auf App\Http\Middleware\VerifyCsrfToken::$except, nicht in bootstrap/app.php.
  • Service-Provider-Registrierung geht ins providers-Array in config/app.php, nicht in bootstrap/providers.php.
  • Request Body$request->getContent() ist identisch.
  • Job-TraitsIlluminate\Foundation\Queue\Queueable existiert nicht; die vier separaten Traits einzeln importieren (Dispatchable, InteractsWithQueue, Queueable, SerializesModels).

Häufige Probleme

419 Page Expired am Webhook. CSRF-Ausnahme fehlt. Auf Laravel 11+ bootstrap/app.php prüfen. Nach der Änderung php artisan route:cache && php artisan config:cache.

SignatureVerificationException am Webhook. $request->getContent() verwenden, nicht $request->all(). Auch prüfen, ob eine Middleware (Komprimierung, Body-Parsing) den Payload zwischen TLS-Terminator und Controller verändert. Details in der Fehler-Referenz.

No API key provided nach Deploy. config:cache lief vor dem Schreiben der neuen .env, oder der Cache wurde nicht refresht. php artisan config:clear, mit tinker prüfen, dann neu cachen.

Cannot declare class Customer. Namespace-Kollision. Einen der beiden Imports aliasieren (siehe oben).

Queue-Driver speichert Kunden-E-Mails im Klartext. Der Job serialisiert das Stripe\Event-Objekt. Nur die Event-ID dispatchen, im handle() neu laden.

Nächste Schritte

  • Stripe Webhooks in PHP – der framework-agnostische Guide zu Signatur-Verifizierung, Idempotenz und lokalem Testen mit der Stripe CLI. Der Laravel-Webhook-Abschnitt oben baut darauf auf.
  • Stripe Subscriptions in PHP – der vollständige Lifecycle inklusive default_incomplete, Proration, Trials und Customer Portal.
  • Stripe Payment Integration in PHP – erster PaymentIntent, 3DS-Handoff. Der Laravel-PaymentController ist der gleiche Flow in einer Laravel-Route.
  • Bei Stripe-PHP-Fehlern aller Art hilft die Fehler-Referenz.

Etwas Ungenaues auf dieser Seite entdeckt?

Fehler melden