Oleksii Siniaiev
Navegación de la página del artículo
Artículos 11 min de lectura March 21, 2026

Suscripciones en Laravel con Cashier y Stripe

En resumen Cashier convierte el camino feliz en tres líneas de código. La facturación en producción es todo lo que rodea ese camino: webhooks, SCA, pagos fallidos, finales de prueba y cambios de plan.…

En esta página

En resumen

  • Cashier convierte el camino feliz en tres líneas de código. La facturación en producción es todo lo que rodea ese camino: webhooks, SCA, pagos fallidos, finales de prueba y cambios de plan.
  • Stripe es la fuente de verdad. Tu base de datos es un modelo de lectura que los webhooks mantienen sincronizado, así que concede el acceso en el manejador del webhook, nunca en el controlador que llamó a create().
  • Los webhooks llegan al menos una vez y desordenados. Cualquier efecto secundario (conceder créditos, enviar un correo) debe ser idempotente o se disparará dos veces.
  • Controla el acceso según el estado de la suscripción (subscribed(), onGracePeriod()), no según un booleano que pongas tú. La máquina de estados es el producto.

La primera función de suscripciones que lancé funcionaba perfectamente en la demo y se rompió la primera semana con clientes reales. La demo usaba una tarjeta de prueba de EE. UU., pasaba directamente a active y concedía el acceso de inmediato. Luego se registró un cliente de España, su banco activó 3D Secure, la suscripción quedó en incomplete y mi código ya había puesto un flag is_premium en true dentro del controlador. Tenía acceso por el que no había pagado. Una semana después falló una renovación, Stripe pasó la suscripción a past_due y nada en mi aplicación lo notó, porque yo leía mi propio flag en lugar del estado de la suscripción.

Nada de esto es un problema de Cashier. Cashier es realmente bueno. El problema es que todos los tutoriales —incluido el que era este artículo— se detienen en newSubscription()->create() y lo llaman una guía completa. Esas tres líneas son el 10% fácil. Esto es el otro 90%: el flujo de webhooks, los estados de suscripción que de verdad hay que manejar y cómo estructurar el código para que un cambio de precio no se propague por veinte controladores.

Qué te da Cashier realmente (y qué no)

Cashier es un envoltorio sobre la API de Stripe que mapea los objetos de facturación de Stripe a modelos de Eloquent. Te da un trait Billable, tres tablas en la base de datos, métodos expresivos como subscribed() y swap() y —esta parte se infravalora— un controlador de webhooks que mantiene tus tablas locales sincronizadas con Stripe automáticamente.

Lo que no te da: una decisión sobre cuándo un pago fallido debe revocar el acceso, una interfaz para la confirmación de SCA, lógica de negocio idempotente ni ninguna opinión sobre cómo modelar los permisos. Eso es cosa tuya. Cashier se ocupa de la fontanería de la facturación; las reglas de producto en torno a la facturación las diseñas tú.

Configuración mínima para producción

Instala el paquete y publica sus migraciones. Las versiones nuevas de Cashier usan vendor:publish en lugar del antiguo comando cashier:table:

Bash
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

Eso crea las tablas subscriptions y subscription_items y añade columnas de Stripe a tu tabla users. Luego el entorno —y fíjate en la tercera variable, la que se olvida hasta que los webhooks fallan en silencio:

Code
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Añade el trait Billable al modelo que posee la relación de facturación. Suele ser User, pero en un producto B2B a menudo es un Team o una Account —decídelo pronto, porque moverlo después duele:

Code
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Crear una suscripción — y por qué el camino feliz miente

La llamada expresiva que todos te muestran:

Code
$user
    ->newSubscription('default', config('billing.prices.premium_monthly'))
    ->create($paymentMethodId);

Ya hay dos cosas distintas respecto a la versión del tutorial. El precio viene de la configuración, no de una cadena fija, porque los IDs de precio difieren entre el modo de prueba y el de producción de Stripe y volverán a cambiar cuando ajustes precios. Y el resultado de create() no garantiza una suscripción activa.

La Autenticación Reforzada de Cliente rompe el camino feliz

Las tarjetas europeas (y cada vez más otras) requieren confirmación 3D Secure. Cuando ocurre, create() tiene éxito pero el estado de la suscripción es incomplete, no active. El cliente todavía tiene que confirmar el pago. Si concedes el acceso justo después de que create() retorne, se lo das a gente que en realidad no ha pagado.

Por eso aquí no se concede el acceso. El trabajo del controlador es iniciar la suscripción y, si el pago necesita confirmación, entregar al cliente un payment intent para completarlo. El acceso se concede después, cuando Stripe confirma el pago y te lo comunica mediante un webhook.

Los webhooks son la verdadera fuente de verdad

Cashier incluye un controlador de webhooks. Apunta un endpoint de webhook de Stripe a /stripe/webhook, define STRIPE_WEBHOOK_SECRET y excluye esa ruta de la protección CSRF. De serie, Cashier escucha los eventos de suscripción y de factura y mantiene tus tablas locales correctas: una renovación, una cancelación, un pago fallido o una confirmación de SCA actualizan la fila en tu base de datos sin que escribas una sola línea.

Lo que Cashier no puede hacer es ejecutar tus efectos secundarios: aprovisionar un espacio de trabajo, conceder créditos de API, enviar un correo de bienvenida. Esos los añades escuchando el evento WebhookReceived de Cashier:

Code
use Laravel\Cashier\Events\WebhookReceived;

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.payment_succeeded') {
            $this->provisionAccess($event->payload['data']['object']);
        }
    }
}

Dos reglas hacen esto seguro, y saltarse cualquiera de ellas es la forma más común de que la facturación de suscripciones se rompa en producción.

Los webhooks llegan al menos una vez y desordenados

Stripe entregará de vez en cuando el mismo evento dos veces, y no garantiza el orden. Puedes recibir subscription.updated antes que el subscription.created al que lógicamente sigue. Cada manejador debe ser seguro de ejecutar dos veces y fuera de secuencia.

Haz los efectos secundarios idempotentes. Antes de conceder 500 créditos de API, comprueba si esta factura ya se ha procesado —indéxalo por el ID de la factura o del evento de Stripe, guarda que lo manejaste y no hagas nada en la segunda entrega. Un correo de bienvenida protegido por una marca welcomed_at no se enviará dos veces. El coste de olvidarlo son créditos cobrados por duplicado y correos duplicados, y te enterarás por un cliente molesto.

Los estados de suscripción que de verdad hay que manejar

«Suscrito o no» son dos estados. La facturación real de Stripe tiene al menos seis que cambian lo que tu producto debe hacer. Esta es la tabla que tengo al lado cuando configuro el control de acceso:

EstadoQué significaComprobación de Cashier¿Conceder acceso?
trialingEn periodo de prueba, aún sin cobroonTrial()
activePagado y al díasubscribed()
incompleteEl primer pago necesita confirmación SCAhasIncompletePayment()No
past_dueFalló una renovación; Stripe reintentasubscription()->past_due()Tú decides (ver abajo)
cancelada, en periodo de graciaCancelada pero pagada hasta fin de periodoonGracePeriod()Sí, hasta fin de periodo
cancelada, finalizadaEl periodo terminó, el acceso caducósubscribed() es falseNo

La fila past_due es una decisión de producto, no técnica. Stripe ejecuta sus reintentos de cobro durante varios días. ¿Cortas el acceso en el momento en que falla una renovación, o mantienes al cliente durante la ventana de reintentos y solo revocas cuando Stripe se rinde y pasa a canceled? Cortar de inmediato reduce la fuga de ingresos pero castiga a un cliente cuya tarjeta simplemente caducó. La mayoría de los SaaS mantienen el acceso durante la ventana de reintentos y muestran un aviso de «tu pago falló, actualiza tu tarjeta». No hay una respuesta correcta; solo una decisión que conviene tomar a conciencia y no por accidente.

Pruebas, cambios de plan y cancelaciones

Las pruebas vienen en dos formas. Una prueba con tarjeta por adelantado usa trialDays() al crear. Una prueba sin tarjeta —dejar entrar a la gente antes de pedir el pago— usa una marca de tiempo en el modelo y todavía sin suscripción en Stripe:

Code
// Tarjeta obligatoria por adelantado
$user->newSubscription('default', $priceId)
    ->trialDays(14)
    ->create($paymentMethodId);

// Sin tarjeta por adelantado (prueba genérica)
$user->trial_ends_at = now()->addDays(14);
$user->save();

La prueba sin tarjeta es mejor para la conversión pero significa que tienes que manejar el momento en que la prueba termina sin método de pago —bloquear la aplicación y pedir una tarjeta antes de que pase trial_ends_at.

Cambiar de plan es una sola llamada, pero el prorrateo es el detalle que genera tickets de soporte:

Code
$user->subscription('default')->swap($newPriceId);            // prorratea por defecto
$user->subscription('default')->noProrate()->swap($newPriceId); // sin prorrateo

La cancelación es donde los equipos pierden en silencio la buena voluntad del cliente. cancel() no termina la suscripción de inmediato —se cancela al final del periodo, así que el cliente conserva el acceso que ya pagó, que es lo que reporta onGracePeriod(). cancelNow() revoca de inmediato y no reembolsa nada. Usa cancel() salvo que tengas una razón concreta para no hacerlo:

Code
$user->subscription('default')->cancel();    // acceso hasta fin de periodo
$user->subscription('default')->resume();    // cambió de idea durante la gracia
$user->subscription('default')->cancelNow(); // inmediato, sin gracia

Pon la lógica de facturación tras un único límite

El error que vuelve inmantenible el código de suscripciones es esparcir comprobaciones $user->subscribed('default') por controladores, vistas Blade y jobs. El día que añadas un plan anual o un segundo producto, estarás cazando cada una de ellas.

Mantén un único método que responda a la pregunta de producto —«¿puede este usuario usar esta función?»— y deja que internamente lea el estado de la suscripción. Todo lo demás llama a ese método y no sabe nada de Stripe:

Code
public function canAccessPremium(): bool
{
    return $this->subscribed('default')
        || $this->onTrial()
        || $this->subscription('default')?->onGracePeriod();
}

Es la misma idea de inversión de dependencias aplicada a la facturación: la aplicación depende de una pregunta estable, no de los detalles volátiles de cómo Stripe la responde. Si quieres el razonamiento tras ese límite, lo desarrollé en inyección e inversión de dependencias en Laravel. Mantener Stripe tras un servicio enfocado es también lo que hace manejables los modos de fallo del artículo sobre seguridad en aplicaciones web: la verificación de firma del webhook y las comprobaciones de acceso viven en un solo lugar auditable, no en veinte.

Errores que veo en código real de suscripciones en Laravel

Aprovisionar en el controlador

Conceder acceso justo tras create() ignora la SCA y los fallos de petición. Aprovisiona en el manejador del webhook.

Manejadores de webhook no idempotentes

La entrega al menos una vez implica duplicados. Indexa los efectos secundarios por el ID de evento o factura de Stripe y no hagas nada en la repetición.

Un flag is_premium casero

Se desincroniza de Stripe en cuanto un pago falla en silencio. Lee el estado de la suscripción; no lo repliques a mano.

Saltarse el secreto del webhook

Sin STRIPE_WEBHOOK_SECRET, cualquiera puede enviar eventos falsos a tu endpoint. Verifica cada firma.

IDs de precio fijos en el código

Difieren entre modo de prueba y producción y cambian al reajustar precios. Ponlos en la configuración, no en el código.

Tratar la cancelación como inmediata

Usar cancelNow() por defecto tira el acceso que el cliente ya pagó. Usa cancel() por defecto.

Probar flujos de suscripción sin esperar un mes

No puedes validar renovaciones ni vencimientos de prueba esperando tiempo real. Los test clocks de Stripe permiten crear un cliente asociado a un reloj simulado y adelantarlo más allá de una renovación o del final de una prueba, y luego comprobar que tus webhooks se dispararon y tu lógica de acceso respondió. Combínalo con el modo de prueba de Stripe y los propios helpers de prueba de Cashier, y cubrirás los casos que de verdad se rompen —renovación fallida, confirmación de SCA, prueba que termina sin tarjeta— en tu pipeline de CI, no en producción.

Los flujos que merecen una prueba cada uno: una suscripción correcta, una renovación que falla y pasa a past_due, una cancelación que conserva el acceso del periodo de gracia, y un webhook entregado dos veces que no debe aprovisionar por duplicado. Esos cuatro cubren la mayoría de los incidentes que he visto.

FAQ

¿Dónde concedo el acceso, en el controlador o en el webhook?
En el webhook. El controlador inicia la suscripción, pero el pago aún puede necesitar confirmación SCA, y la petición HTTP puede fallar después de que create() tenga éxito. Conceder el acceso en invoice.payment_succeeded dentro de un manejador de webhook es el único punto donde sabes que el cliente realmente pagó.
¿Cómo evito que los webhooks de Stripe ejecuten mi lógica dos veces?
Haz el manejador idempotente. Guarda el ID de evento o factura de Stripe una vez procesado y compruébalo antes de volver a ejecutar efectos secundarios. Stripe entrega al menos una vez, así que los duplicados son esperables, no excepcionales.
¿Cuál es la diferencia entre cancel() y cancelNow() en Cashier?
cancel() cancela al final del periodo de facturación actual, así que el cliente conserva el acceso que pagó —Cashier lo reporta como onGracePeriod(). cancelNow() termina la suscripción de inmediato sin periodo de gracia. Usa cancel() por defecto.
¿Por qué mi suscripción se queda en «incomplete»?
El primer pago requiere Autenticación Reforzada de Cliente (3D Secure) y el cliente no la ha confirmado. create() retornó, pero la suscripción no está activa. Muestra al cliente la confirmación del payment intent; no se debe conceder acceso hasta que el pago tenga éxito.
¿Debo cortar el acceso en el momento en que falla un pago?
Es una decisión de producto. Stripe reintenta los pagos fallidos durante varios días (past_due). La mayoría de los SaaS mantienen el acceso durante la ventana de reintentos y muestran un aviso para actualizar la tarjeta, revocando solo cuando Stripe se rinde. Decídelo a conciencia y codifícalo en un único lugar.

Artículos relacionados

Actualizado: June 17, 2026

Compartir este artículo

LinkedIn X Email

Contacto

¿Trabajas en algo parecido? Hablemos.

Estoy abierto a hablar de arquitectura, Laravel, WordPress, rendimiento y problemas prácticos de implementación.

Enviar un mensaje Ver proyectos

Explorar más

Artículos

June 14, 2026

Parte 3. Un mes con un diario de IA: cómo encontrar patrones entre sueño, estrés y entrenamientos

Cómo analizar un diario de IA tras el primer mes: correcciones de transcripción, reflexión…
Artículos

June 14, 2026

Parte 2. Hermes Agent + DeepSeek en Ubuntu: guía completa de un diario con IA en Telegram

Guía paso a paso: Hermes Agent y DeepSeek en Ubuntu, bot privado de Telegram,…
Artículos

June 14, 2026

Parte 1. Cómo convertí un viejo portátil gaming en un diario de bienestar con IA

Cómo un viejo Xiaomi Mi Gaming Laptop se convirtió en servidor doméstico con IA:…