Suscripciones con Stripe en PHP
Una suscripción de Stripe ata tres objetos: un Customer que paga, un Price que describe lo que se repite, y una Subscription que los une en un calendario de facturación. Una vez creada, Stripe renueva la Subscription, emite un Invoice por cada ciclo, cobra al método de pago guardado y dispara eventos de webhook para que tu sistema reaccione. Tu backend PHP crea los objetos y escucha eventos; Stripe maneja el loop de facturación.
¿Se puede usar Stripe para cobros recurrentes? Sí, y el SDK de PHP (stripe/stripe-php) es una librería oficial mantenida por Stripe. Si aún no has hecho la configuración inicial, la guía de integración de pagos cubre la instalación del SDK y las claves API.
Los ejemplos apuntan al API actual (2025-03-31 en adelante). Si tu cuenta sigue fijada en una versión anterior, dos cosas cambiaron en la release 2025-03-31: el campo de referencia de pago en Invoice se reestructuró (
payment_intentfue reemplazado porconfirmation_secret+ el sub-recursopayments), y el endpointInvoice::upcomingse renombró aInvoice::createPreview.
Modelo mental: tres objetos
Una Subscription es un objeto de unión. No se puede crear de la nada: necesita un Customer (quién se cobra, cuál es su método de pago por defecto) y al menos un Price (cuánto, en qué moneda, con qué frecuencia). Las piezas van en este orden:
- Un
Productdescribe lo que vendes (“Plan Pro”, “Nivel Gold”). Se crea una vez en el Dashboard o vía API. - Un
Pricele pone dinero al Product: monto, moneda, intervalo. Un Product suele tener varios Prices (mensual, anual, distintas monedas). - Un
Customeres el comprador. Lleva el email, el método de pago por defecto y las suscripciones. - Una
Subscriptiones el cobro recurrente: customer, uno o más prices, un calendario, un estado.
El objeto Plan de tutoriales antiguos está deprecado. El API todavía acepta IDs de Plan por compatibilidad, pero integraciones nuevas usan Product + Price. Si ves $stripe->plans->create(...) en un blog de 2019, tradúcelo a Price.
Suscripción mínima
El flujo más corto: crear el Customer, adjuntar un PaymentMethod, crear la Subscription, y expandir la primera factura para que el client_secret de confirmación 3D Secure venga en la misma respuesta. Este ejemplo de Stripe Subscription cubre el caso mínimo.
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$customer = \Stripe\Customer::create([
'email' => $user->email,
'payment_method' => $paymentMethodId,
'invoice_settings' => [
'default_payment_method' => $paymentMethodId,
],
]);
$subscription = \Stripe\Subscription::create([
'customer' => $customer->id,
'items' => [[ 'price' => 'price_YOUR_MONTHLY_ID' ]],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [ 'save_default_payment_method' => 'on_subscription' ],
'expand' => ['latest_invoice.confirmation_secret'],
]);
$clientSecret = $subscription->latest_invoice->confirmation_secret->client_secret;
// enviar $clientSecret al frontend; stripe.confirmCardPayment maneja 3DS
Tres flags que hacen funcionar el flujo:
payment_behavior: 'default_incomplete' le dice a Stripe que cree la Subscription en estado incomplete si la primera factura necesita autenticación (3D Secure, SCA). Sin este flag, el API falla cuando el banco pide un paso extra y el cliente ve un error opaco. Con él, obtienes un client_secret en el confirmation_secret de la factura y lo confirmas en el navegador.
expand: ['latest_invoice.confirmation_secret'] trae el secreto de confirmación en la respuesta. En las versiones actuales del API, el client secret de la primera factura vive en latest_invoice.confirmation_secret.client_secret, no en latest_invoice.payment_intent.client_secret. Esa ruta anterior es la razón por la que tantos ejemplos copiados dejaron de funcionar en 2025.
save_default_payment_method: 'on_subscription' promueve el método de pago que pagó la primera factura al método por defecto del Customer. Las renovaciones lo usan a partir de entonces. Sin esto, el segundo ciclo falla con “no payment method on file” aunque el primero haya funcionado.
La Subscription arranca en incomplete. Cuando la confirmación del lado del cliente tiene éxito, Stripe la cambia a active y dispara customer.subscription.created más invoice.paid.
Vía Checkout
Si no quieres recoger el PaymentMethod tú mismo, delega todo el flujo a Stripe Checkout. Apunta line_items a un Price recurrente y cambia mode:
$session = \Stripe\Checkout\Session::create([
'mode' => 'subscription',
'line_items' => [[ 'price' => 'price_YOUR_MONTHLY_ID', 'quantity' => 1 ]],
'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => 'https://example.com/pricing',
]);
Stripe crea el Customer, recoge el PaymentMethod, ejecuta 3DS si hace falta, y crea la Subscription. No pases payment_intent_data junto con mode: 'subscription': el API devuelve invalid_request. Usa subscription_data si necesitas configurar días de trial o metadata en la Subscription. Más detalles en la guía de Checkout.
Eventos de webhook que importan
Las suscripciones emiten más eventos que cualquier otro objeto de Stripe, y la mayoría se pueden ignorar. La lista práctica:
| Evento | Se dispara cuando | Por qué importa |
|---|---|---|
customer.subscription.created | La Subscription pasa a activa por primera vez | Provisionar acceso |
customer.subscription.updated | Cambió el estado, items o flag de cancelación | Upgrades, downgrades, pausas |
customer.subscription.deleted | La Subscription terminó (cancelada o agotó reintentos) | Revocar acceso |
invoice.paid | Una factura se pagó (inicial + cada renovación) | Extender acceso otro ciclo |
invoice.payment_failed | Smart Retries agotó los reintentos o un intento falló | Avisar al usuario, flujo de dunning |
customer.subscription.trial_will_end | Tres días antes de que termine el trial | Recordar al cliente que agregue método de pago |
Suscríbete a estos específicamente en vez de todo el namespace de Subscription. La guía de webhooks cubre la verificación de firma y la idempotencia por event.id, ambas obligatorias para suscripciones por el volumen de eventos.
El orden no está garantizado. Stripe puede entregar customer.subscription.updated antes que customer.subscription.created. Trata cada evento como un hecho independiente, no como un paso en una secuencia.
El evento canónico de “¿pagó?” es invoice.paid, no customer.subscription.updated. Que la Subscription pase a active no significa que el dinero se movió; significa que la Subscription está programada. Espera al evento de factura antes de desbloquear funcionalidades de pago.
Cancelación: dos variantes
Dos formas de terminar una Subscription, con consecuencias de facturación muy distintas.
Cancelación inmediata termina la Subscription al instante y detiene la facturación futura. El cliente pierde acceso de inmediato, aunque ya pagó el mes:
\Stripe\Subscription::cancel($subscriptionId);
Cancelar al final del período mantiene la Subscription activa hasta que termine el ciclo pagado, y luego la termina sin volver a cobrar:
\Stripe\Subscription::update($subscriptionId, [
'cancel_at_period_end' => true,
]);
En la mayoría de los flujos de consumidor, la segunda opción es la correcta: el cliente pagó hasta el 30, dale acceso hasta el 30. Llamar a cancel() de inmediato desperdicia un mes que ya pagaron y te deja un reembolso manual por procesar.
Revertir una cancelación pendiente es el mismo update al revés: cancel_at_period_end: false. Stripe mantiene la Subscription tal cual y factura normalmente en el siguiente ciclo.
Stripe dispara customer.subscription.updated cuando configuras cancel_at_period_end, y customer.subscription.deleted solo cuando la cancelación de fin de período se ejecuta. No revoques acceso en el evento updated, eso anula el cancel-at-period-end. Espera al deleted.
Períodos de prueba (trial)
Stripe tiene dos maneras de expresar un trial, y hacen cosas distintas:
// Relativo: el trial dura esta cantidad de días desde ahora
'trial_period_days' => 14,
// Absoluto: el trial termina en este timestamp Unix exacto
'trial_end' => strtotime('+30 days'),
Usa trial_period_days para flujos estándar de “regístrate, obtén 14 días gratis”. Usa trial_end cuando necesites alinear trials con una fecha calendario: una campaña de lanzamiento que termina en un momento específico, o extender el trial de un cliente puntual. Mezclar ambos en la misma Subscription devuelve invalid_request.
Antes, las suscripciones en trial requerían método de pago desde el inicio. Ahora puedes iniciar trials sin recoger un método de pago configurando trial_settings.end_behavior.missing_payment_method en 'cancel', 'pause' o 'create_invoice'. Si quieres “trial gratis, sin tarjeta de crédito”, este es el setting.
Tres días antes de que termine el trial, Stripe dispara customer.subscription.trial_will_end. Para trials de menos de tres días, lo dispara inmediatamente al crear la Subscription. Es la señal para enviar email al cliente, recordarle cuánto se le va a cobrar, y quizás ofrecer un descuento para retenerlo. Las suscripciones que pasan silenciosamente de “gratis” a “cobro” son fuente común de contracargos.
Cambio de plan y prorrateo
Puedes intercambiar Prices en una Subscription activa. Lo que cambia es el dinero:
$subscription = \Stripe\Subscription::retrieve($subscriptionId);
\Stripe\Subscription::update($subscriptionId, [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID',
]],
'proration_behavior' => 'create_prorations',
]);
proration_behavior acepta tres valores:
create_prorations(por defecto) – Stripe calcula la porción no usada del plan anterior, acredita al cliente, y cobra el resto prorrateado del nuevo. La diferencia aparece en la siguiente factura.none– sin prorrateo, la siguiente factura usa el precio nuevo por el ciclo completo. Útil para downgrades que quieras que apliquen en el siguiente ciclo.always_invoice– misma matemática quecreate_prorations, pero factura de inmediato en vez de esperar al siguiente ciclo. El flujo clásico de upgrade: cambiar ahora, pagar la diferencia ahora.
Para previsualizar el impacto antes de confirmar, usa Invoice::createPreview:
$preview = \Stripe\Invoice::createPreview([
'customer' => $customer->id,
'subscription' => $subscriptionId,
'subscription_details' => [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID',
]],
'proration_behavior' => 'always_invoice',
],
]);
echo "Nuevo cargo: {$preview->amount_due} {$preview->currency}";
Esto devuelve la factura que Stripe generaría. Úsalo para mostrar al cliente “se te cobrará $X hoy al cambiar” antes de que confirme. Antes de la release 2025-03-31 este endpoint era Invoice::upcoming; la llamada anterior sigue funcionando en versiones legacy pero el código nuevo debería usar createPreview.
Obtener el cargo de una Subscription
Pregunta clásica de Stack Overflow: “Creé una Subscription, ¿cómo encuentro el PaymentIntent o el Charge?” La cadena es larga porque una Subscription apunta a un Invoice, el Invoice tiene uno o más intentos de pago, y cada intento apunta a un PaymentIntent que apunta a un Charge.
Desde la release 2025-03-31, el Invoice ganó un sub-recurso payments para soportar intentos de pago múltiples. El PaymentIntent ya no es un campo de nivel superior en el Invoice:
$subscription = \Stripe\Subscription::retrieve([
'id' => $subscriptionId,
'expand' => ['latest_invoice.payments'],
]);
$payment = $subscription->latest_invoice->payments->data[0]->payment;
$pi = \Stripe\PaymentIntent::retrieve($payment->payment_intent);
$charge = \Stripe\Charge::retrieve($pi->latest_charge);
Dos puntos de migración al leer tutoriales antiguos:
- Antes del API
2022-11-15,PaymentIntent.chargesera una lista; esa versión lo reemplazó conlatest_chargecomo un solo string ID. - Antes de la release 2025-03-31,
invoice.payment_intentera un campo expandible directo. En versiones actuales, iterainvoice.payments.data.
Customer Portal
La interfaz de “cambiar plan, actualizar tarjeta, cancelar” son muchos formularios y diálogos de confirmación. Stripe envía uno listo: el Customer Portal. Redirige al cliente a una página hosted de Stripe, hacen lo que necesiten, y Stripe emite los mismos eventos de webhook que ya manejas.
$portal = \Stripe\BillingPortal\Session::create([
'customer' => $customerId,
'return_url' => 'https://example.com/account',
]);
header('Location: ' . $portal->url, true, 303);
exit;
Esa es toda la integración para “permitir que los clientes cancelen sin escribir a soporte”. El comportamiento del portal (qué pueden cambiar, si la cancelación es inmediata o a fin de período, si ven facturas) se configura una vez en el Dashboard (Settings > Billing > Customer portal) o vía \Stripe\BillingPortal\Configuration::create.
Probar renovaciones con test clocks
Cada guía de primera generación sobre suscripciones dice “crea una Subscription con intervalo corto y espera”. Stripe provee test clocks precisamente para esto:
$clock = \Stripe\TestHelpers\TestClock::create([
'frozen_time' => time(),
]);
$customer = \Stripe\Customer::create([
'email' => '[email protected]',
'test_clock' => $clock->id,
]);
// Crear Subscription sobre $customer como de costumbre...
// Avanzar un mes
$clock->advance([
'frozen_time' => time() + 31 * 86400,
]);
Stripe procesa la Subscription en el reloj avanzado: emite la factura, ejecuta Smart Retries en pagos fallidos, dispara trial_will_end si aplica. Solo funciona en test mode, pero así es como realmente ejercitas el flujo de renovación en CI. El Stripe CLI tiene stripe trigger que envía eventos sintéticos, pero esos eventos son fabricados; los test clocks manejan la maquinaria real de Subscription.
Solución de problemas
invalid_request: payment_intent_data not allowed on subscription
Copiar un snippet de Checkout Session de una integración de pago único y dejar payment_intent_data en la configuración es la forma más rápida de provocar este error. El modo subscription tiene su propio subscription_data. Elimina payment_intent_data, usa subscription_data si necesitas metadata en la Subscription.
latest_invoice->payment_intent es null
En API 2025-03-31 y posterior, Invoice.payment_intent se eliminó. Código que lee $subscription->latest_invoice->payment_intent->client_secret devuelve null o lanza un notice de “undefined property” según la configuración de PHP. Actualiza a latest_invoice.confirmation_secret.client_secret con el expand correspondiente.
Invoice stuck en draft
Si se configuró auto_advance: false en la factura, nunca se finaliza sola. Llama a Invoice::finalizeInvoice($invoiceId) para moverla a open, y Invoice::pay($invoiceId) para cobrar. El flujo automático tiene auto_advance: true por defecto; el manual es solo para workflows de “previsualizar, revisar, luego cobrar”.
La Subscription dice active pero el cliente no tiene acceso
active significa “Stripe está facturando esta Subscription”. No significa que la factura actual se pagó. Una Subscription puede estar active con un latest_invoice fallido mientras Smart Retries trabajan en el calendario. Controla el acceso con eventos invoice.paid y current_period_end de la Subscription, no solo con 'active' === $status.
La renovación cobró con la tarjeta vieja
Las tarjetas caducan. El card updater de Stripe refresca los datos de forma transparente para muchos emisores, pero no para todos. Cuando el actualizador no aplica, la renovación falla y el cliente recibe invoice.payment_failed. Construye el flujo de dunning alrededor de ese evento: email de aviso, prompt para actualizar el método de pago, y opcionalmente un período de gracia antes de revocar acceso.
Qué sigue
- La guía de Stripe en Laravel sin Cashier cubre el service provider con
StripeClientcomo singleton, la excepción CSRF para webhooks y la colisión de namespace con el modeloCustomerde Eloquent. - La referencia del API de Stripe en PHP cubre paginación, expand y la jerarquía de excepciones.
- Para entender las comisiones que Stripe cobra por cada renovación, la guía de pricing desglosa la lógica de fees en código.
- Cuando algo falle, el catálogo de errores comunes de Stripe en PHP cubre la taxonomía completa.
¿Has visto algo inexacto en esta página?
Reportar un error