phpguzzle.org
enrudees
Stripe – Docs

Stripe en Laravel sin Cashier

Cashier enmarca la decisión. La mayoría de tutoriales de Laravel con Stripe recurren a laravel/cashier-stripe porque está mantenido oficialmente y las pantallas de facturación ya vienen armadas: trials, facturas, el trait Billable en User. El costo es una segunda tanda de migraciones, convenciones sobre dónde vive el estado de facturación, y un shim que tiene que publicar un release después de cada major de stripe-php. Para un SaaS con suscripciones simples, Cashier es la elección correcta. Para un marketplace multi-tenant, un producto sin suscripciones, o un equipo que ya tiene sus propias opiniones sobre dónde debería vivir stripe_customer_id, Cashier estorba.

Esta guía toma la ruta opuesta para la integración de Stripe en Laravel. Instalar stripe/stripe-php directamente, registrar \Stripe\StripeClient como singleton en un service provider, mantener los controladores delgados detrás de una clase de servicio, y manejar webhooks en Laravel puro.

Los ejemplos apuntan a Laravel 11 y 12 (middleware en bootstrap/app.php) y la versión actual del API de Stripe (2025-03-31 en adelante). Donde Laravel 10 difiere, hay una nota al final.

Laravel y paquetes de Stripe

Dos paquetes principales. laravel/cashier-stripe es el oficial y maneja suscripciones de punta a punta: User::newSubscription()->create($token), PDFs de facturas, cupones, todo. spatie/laravel-stripe-webhooks es más acotado: solo maneja la parte entrante de webhooks, dándote un dispatcher limpio con un Job por tipo de evento sobre stripe/stripe-php. Fuera de esos, está stripe/stripe-php, el SDK mantenido por Stripe y lo que Cashier usa internamente.

El árbol de decisión es más corto de lo que sugieren los paquetes:

Necesitas…Elige
SaaS de suscripciones con planes estándar, trials, facturasCashier
Pagos únicos, marketplaces, facturación por uso, Connectstripe/stripe-php directo
Solo infraestructura de webhook, el resto del código Stripe es tuyospatie/laravel-stripe-webhooks
Control total sobre schema y flujo, mínima magia del frameworkstripe/stripe-php directo (esta guía)

Cashier no es un wrapper delgado. Agrega tablas subscriptions, subscription_items y algunas más a tu base de datos, asume que el registro facturable es tu User, expone objetos de Stripe a través de su propia API fluent ($user->subscription('default')->cancel()), y espera que operes en su vocabulario. Cuando tu historia de facturación encaja con ese vocabulario, ahorra semanas. Cuando no encaja (cobras por asiento en múltiples equipos del usuario, o ya modelaste las entidades de Stripe en tu dominio), Cashier se convierte en código contra el que peleas.

Instalación y configuración

Agrega el SDK a composer.json:

composer require stripe/stripe-php

Pon las claves en .env:

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

Y exponlas a través de config/services.php, no como constantes ni llamadas estáticas:

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

Dos razones por las que esto importa. Primero, env() fuera de un archivo config devuelve null después de ejecutar php artisan config:cache, porque Laravel ya no carga .env al arrancar. Si lees env('STRIPE_SECRET') en un controlador, funciona en tu portátil y falla en producción la primera vez que alguien ejecuta config:cache. Segundo, config('services.stripe.secret') es mockeable por test vía Config::set(...), mientras que las llamadas estáticas y lecturas de env() no lo son.

Después de cambiar .env en un entorno cacheado, recuerda re-ejecutar el cache:

php artisan config:cache

Saltarse el refresh es el bug más común de “roté la clave y nada la lee” en deploys de Laravel con Stripe.

Service provider

El patrón estático \Stripe\Stripe::setApiKey() sigue funcionando, pero es estado global mutable y dificulta setups multi-tenant (diferentes cuentas Stripe por organización). Registra \Stripe\StripeClient como singleton:

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

Regístralo en bootstrap/providers.php (Laravel 11+) o config/app.php (Laravel 10 y anterior):

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

Ahora los controladores y servicios pueden type-hintear StripeClient y el contenedor de Laravel les entrega la instancia configurada:

public function __construct(private StripeClient $stripe) {}

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

Clase de servicio

Canalizar todo a través de una clase de servicio mantiene los controladores legibles y te da un punto para logging, métricas y reintentos:

// 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) {
                // El Customer fue eliminado en Stripe (típico tras confusión test/live).
                // Cae por aquí y crea uno nuevo.
            }
        }

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

La columna stripe_customer_id en users es una migración:

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

Esa columna, un stripe_subscription_id en el modelo que tenga la suscripción, y una tabla processed_webhooks suelen ser todo el schema. Cashier agrega bastante más.

Colisión de namespace con Customer de Eloquent

Si tu dominio ya tiene un App\Models\Customer (habitual en apps B2B, facturación, marketplaces), use Stripe\Customer; y use App\Models\Customer; en el mismo archivo no van a resolver ambos. PHP lanza error en tiempo de compilación: Cannot use App\Models\Customer as Customer because the name is already in use.

Dos soluciones, ambas molestas; elige la menos molesta en tu caso:

// Opción 1: alias para la clase de 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();
}
// Opción 2: alias para el modelo 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();
}

La opción 1 es la que termina en la mayoría de los codebases: los modelos Eloquent pesan más en una app Laravel que las clases del SDK, así que aceptas la fricción en el import menos tocado. Hazlo de forma consistente; mezclar ambos alias en diferentes archivos del mismo módulo es un riesgo durante refactors.

Aceptar un pago único

Para el flujo completo de PaymentIntent (crear, confirmar, manejar 3DS), la mecánica está cubierta en la guía de integración de pagos. La versión Laravel es el mismo código dentro de una acción de controlador:

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

El frontend confirma el PaymentIntent con Stripe.js usando el client_secret; el estado final llega a tu servidor vía el webhook payment_intent.succeeded. No esperes la respuesta HTTP de la confirmación para cumplir la orden: los usuarios cierran pestañas, las redes se caen. El webhook es la fuente de verdad.

Suscripción sin Cashier

El ciclo de vida completo de suscripciones (Product/Price, default_incomplete, prorrateo, Customer Portal) está en la guía de suscripciones de Stripe. En Laravel vive en un método de servicio:

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

Dos datos viven en users: stripe_subscription_id para poder recuperarla, y stripe_subscription_status para que las verificaciones de acceso no necesiten round-trip a Stripe. Mantén el status fresco vía webhooks (customer.subscription.updated, customer.subscription.deleted); no confíes en un valor que lleva una hora desactualizado.

Webhooks en Laravel

La verificación de firma, la trampa del raw body, la idempotencia y las pruebas con CLI están en la guía de webhooks. Las piezas específicas de Laravel son tres: la excepción CSRF, la lectura del body crudo y el grupo de rutas.

Pon el webhook en un controlador dedicado, fuera de cualquier middleware de auth o CSRF:

// 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'); // STRIPE_WEBHOOK_SECRET en .env

        try {
            $event = Webhook::constructEvent($payload, $signature, $secret);
        } catch (SignatureVerificationException) {
            abort(400, 'Firma inválida');
        } catch (\UnexpectedValueException) {
            abort(400, 'Payload inválido');
        }

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

        return response('', 200);
    }
}

$request->getContent() devuelve la cadena cruda. $request->all() o $request->json() ya habrán sido decodificados del JSON y re-encodificarlos no coincidirá con la secuencia de bytes que Stripe firmó. Esta es la razón más común por la que constructEvent lanza SignatureVerificationException en una app Laravel.

Excluye la ruta del webhook del CSRF. En Laravel 11+ va en bootstrap/app.php:

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

En Laravel 10 y anterior es la propiedad $except en App\Http\Middleware\VerifyCsrfToken. Sin esto, cada webhook devuelve 419 Page Expired antes de que tu controlador se ejecute. Stripe reintenta durante tres días y no ves nada.

La ruta misma debe coincidir con la exclusión CSRF – la cadena es exacta, sin slash inicial:

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

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

Quien registre la ruta bajo /api/stripe/webhook o webhooks/stripe tiene que ajustar la entrada en el array except correspondientemente, o el filtro CSRF se aplica igual.

La trampa de las colas: PII en payloads de jobs

Esta es la trampa específica de Laravel con Stripe que falta en la mayoría de tutoriales. Despachas un Job con el Stripe\Event como propiedad:

// NO HAGAS ESTO
class DispatchStripeEvent implements ShouldQueue
{
    public function __construct(public \Stripe\Event $event) {}
}

Laravel serializa las propiedades del constructor en el payload del job (en Redis, la base de datos, o donde sea que tu queue driver almacene). Stripe\Event es un objeto complejo. Cuando haces push de un evento payment_intent.succeeded, el payload incluye el email del cliente, nombre, billing_details.address, card.last4 y card.brand del método de pago, y cualquier metadata que hayas adjuntado. Todo eso ahora está en una conexión de cola que probablemente no fue diseñada como almacén de PII.

Pasa solo el event ID y re-obtén dentro del 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,
        };
    }
}

Un call extra al API por job. A cambio: el payload de la cola es un string de ocho caracteres, los PII se quedan en el sistema de Stripe, y el job es re-entrante.

Como el dispatch usa ->onQueue('stripe') con un nombre de cola explícito, el worker tiene que conocer ese nombre. Si no, los jobs se acumulan sin que nadie los procese:

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

Alternativa: omitir ->onQueue(...) y usar la cola por defecto, así basta con el queue:work sin flags.

Cuando la excepción Incomplete de Cashier falla con 3DS

Incluso siguiendo esta guía, a veces terminas usando Cashier porque un compañero lo inició antes de la decisión de usar el SDK directo. Un borde áspero de Cashier vale la pena conocer.

Cuando una nueva Subscription requiere 3D Secure ('requires_action' === $subscription->latest_invoice->payment_intent->status), Cashier lanza Laravel\Cashier\Exceptions\IncompletePayment. La excepción lleva una propiedad pública $payment que envuelve el PaymentIntent. La capturas y redireccionas:

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

Lo que sorprende (el patrón clásico de Stack Overflow y GitHub issues de laravel/cashier-stripe) es que la excepción solo se lanza dentro de create() y swap(), no cuando recuperas una Subscription existente que quedó en estado incomplete. Si un usuario cerró la pestaña durante 3DS y vuelve después, tu código carga una Subscription de Cashier con stripe_status en incomplete, y nada lanza excepción. Tienes que verificar el estado tú mismo:

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

Por esto el enfoque directo con SDK en esta guía es explícito sobre payment_behavior: 'default_incomplete' y devolver confirmation_secret.client_secret al frontend: el handoff de SCA es visible en tu propio código en vez de estar enterrado detrás de una excepción cuya semántica depende de qué método de Cashier llamaste.

spatie/laravel-stripe-webhooks: el camino intermedio

Si el patrón de controlador de webhook + Job + handler por evento se siente como reconstruir la misma infraestructura en cada proyecto, spatie/laravel-stripe-webhooks es el paquete que vale la pena considerar. Maneja la verificación de firma, la excepción CSRF, la lectura del raw body y el despacho de Jobs; tú escribes el handler por tipo de evento y los registras en 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,
    ],
];

El paquete usa stripe/stripe-php internamente, así que sigues sobre el SDK mantenido por Stripe. La única latencia es que después de un major release de stripe-php, esperas a que Spatie publique una versión compatible.

No te da la UI de facturación de Cashier, ni PDFs de facturas, ni la API de suscripciones. Son solo webhooks, que es exactamente lo que la mayoría de proyectos “SDK directo” necesitan de un paquete.

Testing: swap del singleton

Como StripeClient está registrado como singleton en el contenedor, los feature tests pueden intercambiarlo con un stub o un mock:

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

$this->app->instance() reemplaza el singleton durante la duración del test. makePartial() en el mock de StripeClient importa: el StripeClient real resuelve paymentIntents, customers, subscriptions a través de un factory __get en \Stripe\Service\AbstractServiceFactory, no como propiedades públicas. Un mock completo intercepta todo y los servicios no mockeados volverían null. Partial mantiene el factory vivo y te deja sobreescribir solo el servicio que te interesa.

PaymentIntent::constructFrom() es el helper que el SDK usa internamente para deserializar respuestas. El stub resultante se comporta como un PaymentIntent real – también al acceder a ->status o ->amount. Con (object) [...] el controller vería $intent->status como null y entregaría silenciosamente un resultado incorrecto.

Laravel 10 y anterior

Algunos snippets arriba son específicos de Stripe en Laravel 11 porque Laravel 11 eliminó Http/Kernel.php y movió la configuración de middleware a bootstrap/app.php. En Laravel 10 y anterior:

  • Excepción CSRF vive en App\Http\Middleware\VerifyCsrfToken::$except, no en bootstrap/app.php.
  • Registro del service provider va en el array providers de config/app.php, no en bootstrap/providers.php.
  • Traits de JobIlluminate\Foundation\Queue\Queueable no existe; usa los cuatro traits separados (Dispatchable, InteractsWithQueue, Queueable, SerializesModels).

Problemas comunes

419 Page Expired en el webhook. La excepción CSRF no tomó efecto. En Laravel 11+ revisa bootstrap/app.php. Después de editar, ejecuta php artisan route:cache && php artisan config:cache si tu entorno de producción los cachea.

SignatureVerificationException en el webhook. El raw body está siendo modificado. Asegúrate de usar $request->getContent(), no $request->all(). También verifica que ningún middleware (compresión, parsing de body, scanners de seguridad frente a Laravel) esté reescribiendo el payload.

No API key provided en la primera llamada Stripe después de un deploy. Ejecutaste config:cache antes de que el deploy escribiera el nuevo .env, o escribiste la nueva clave y olvidaste re-cachear. php artisan config:clear una vez, confirma con php artisan tinker y config('services.stripe.secret'), luego re-cachea.

Cannot declare class Customer. Colisión de namespace con tu modelo Eloquent. Usa alias para una de las dos (ver la sección de colisión).

Respuesta 403 en el webhook. Tu servidor o un middleware de autenticación bloquea la petición. Verifica que la ruta no esté detrás de auth middleware. Más detalles en la guía de errores.

Qué sigue

¿Has visto algo inexacto en esta página?

Reportar un error