Pagamenti con Stripe in Laravel

Integrare Stripe in un’applicazione Laravel significa poter gestire pagamenti una tantum, abbonamenti ricorrenti, rimborsi e webhooks in modo affidabile. In questa guida vediamo un percorso completo: dalla configurazione dell’account Stripe fino alla messa in produzione, con esempi sia usando Laravel Cashier (consigliato per abbonamenti) sia usando direttamente le API Stripe (consigliato per flussi personalizzati).

Prerequisiti e concetti fondamentali

  • Account Stripe con chiavi API (Publishable e Secret) in modalità test e live.
  • Laravel con PHP e Composer.
  • Webhook endpoint raggiungibile pubblicamente in produzione (in sviluppo si può usare Stripe CLI).

Stripe espone diversi oggetti importanti:

  • Payment Intent: rappresenta l’intento di pagamento; gestisce autenticazione 3D Secure e stati del pagamento.
  • Customer: cliente Stripe collegato all’utente applicativo.
  • Product / Price: definiscono cosa vendi e a quale prezzo (necessari per abbonamenti e Checkout).
  • Checkout Session: pagina di pagamento ospitata da Stripe (spesso la via più semplice e sicura).

Installazione e configurazione di base

Inserisci le chiavi in .env. Mantieni separate test e live e non committare mai le chiavi segrete.

STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxx
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx

In Laravel puoi centralizzare la configurazione in config/services.php:

<?php

return [
    // ...
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
        'webhook' => [
            'secret' => env('STRIPE_WEBHOOK_SECRET'),
        ],
    ],
];

Approccio 1: Stripe Checkout per pagamenti una tantum

Stripe Checkout è una pagina di pagamento già pronta, localizzata, ottimizzata e aggiornata da Stripe. Riduce molto la complessità (IVA, SCA, metodi di pagamento) e limita i rischi di implementazione.

Installazione della libreria Stripe

composer require stripe/stripe-php

Crea un servizio dedicato per incapsulare l’uso di Stripe (buona pratica per testabilità e manutenzione).

<?php

namespace App\Services;

use Stripe\StripeClient;

class StripeService
{
    public function client(): StripeClient
    {
        return new StripeClient(config('services.stripe.secret'));
    }
}

Creare una Checkout Session

Esempio: acquisto singolo (importo fisso). Per importi dinamici usa price_data. In alternativa, per prodotti reali gestiti in Stripe, puoi usare direttamente un price (Price ID).

<?php

namespace App\Http\Controllers;

use App\Services\StripeService;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function create(Request $request, StripeService $stripe)
    {
        $user = $request->user();

        $session = $stripe->client()->checkout->sessions->create([
            'mode' => 'payment',
            'customer_email' => $user->email,
            'line_items' => [[
                'quantity' => 1,
                'price_data' => [
                    'currency' => 'eur',
                    'unit_amount' => 1990, // €19.90 in centesimi
                    'product_data' => [
                        'name' => 'Licenza Pro',
                    ],
                ],
            ]],
            'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url'  => route('checkout.cancel'),
            'metadata' => [
                'user_id' => (string) $user->id,
                'order_ref' => (string) now()->timestamp,
            ],
        ]);

        return redirect()->away($session->url);
    }

    public function success(Request $request, StripeService $stripe)
    {
        $sessionId = $request->query('session_id');

        if (!$sessionId) {
            abort(400, 'Missing session_id');
        }

        $session = $stripe->client()->checkout->sessions->retrieve($sessionId, [
            'expand' => ['payment_intent'],
        ]);

        // Non fidarti solo della redirect: valida anche via webhook.
        return view('checkout.success', [
            'session' => $session,
        ]);
    }

    public function cancel()
    {
        return view('checkout.cancel');
    }
}

Rotte consigliate:

use App\Http\Controllers\CheckoutController;

Route::middleware(['auth'])->group(function () {
    Route::post('/checkout', [CheckoutController::class, 'create'])->name('checkout.create');
    Route::get('/checkout/success', [CheckoutController::class, 'success'])->name('checkout.success');
    Route::get('/checkout/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');
});

Confermare l’ordine: perché servono i webhooks

La pagina di successo indica che l’utente è tornato sul sito, ma non è una prova definitiva di pagamento: l’utente può chiudere la finestra o forzare URL. La conferma “vera” va fatta tramite webhook, ascoltando eventi come:

  • checkout.session.completed
  • payment_intent.succeeded
  • charge.refunded (se gestisci rimborsi)

Approccio 2: Payment Intents + Stripe Elements (flusso custom)

Se vuoi un’esperienza interamente nel tuo frontend (senza Checkout), usi Payment Intents e Stripe Elements. In questo caso il backend crea il Payment Intent e restituisce il client_secret, mentre il frontend completa la conferma con Stripe.js.

Creazione del Payment Intent lato server

<?php

namespace App\Http\Controllers;

use App\Services\StripeService;
use Illuminate\Http\Request;

class PaymentIntentController extends Controller
{
    public function create(Request $request, StripeService $stripe)
    {
        $request->validate([
            'amount' => ['required', 'integer', 'min:50'],
            'currency' => ['nullable', 'string'],
        ]);

        $user = $request->user();

        $intent = $stripe->client()->paymentIntents->create([
            'amount' => (int) $request->amount,
            'currency' => $request->input('currency', 'eur'),
            'automatic_payment_methods' => ['enabled' => true],
            'metadata' => [
                'user_id' => (string) $user->id,
            ],
        ]);

        return response()->json([
            'clientSecret' => $intent->client_secret,
        ]);
    }
}

Rotta API tipica:

use App\Http\Controllers\PaymentIntentController;

Route::middleware(['auth:sanctum'])->post('/stripe/payment-intents', [PaymentIntentController::class, 'create']);

Frontend: conferma del pagamento

Di seguito un esempio essenziale con Stripe.js (da integrare nel tuo stack JS). Nota che questo snippet assume di aver già inizializzato Elements e di avere un endpoint che ritorna clientSecret.

import { loadStripe } from "@stripe/stripe-js";

const stripe = await loadStripe(import.meta.env.VITE_STRIPE_KEY);

// 1) Richiedi il clientSecret al backend
const res = await fetch("/api/stripe/payment-intents", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ amount: 1990, currency: "eur" }),
});
const { clientSecret } = await res.json();

// 2) Conferma il pagamento con Elements
const { error } = await stripe.confirmPayment({
  clientSecret,
  confirmParams: {
    return_url: `${window.location.origin}/pagamento/esito`,
  },
});

if (error) {
  console.error(error.message);
}

Anche qui: la conferma definitiva va sempre via webhook.

Abbonamenti con Laravel Cashier

Se vendi abbonamenti, Laravel Cashier (Stripe) fornisce un layer molto comodo per gestire Customer, pagamenti ricorrenti, periodi di prova e cancellazioni. Cashier dà il meglio quando modelli i piani come prodotti e prezzi su Stripe.

Installazione e migrazioni

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

Aggiungi il trait Billable al tuo modello User:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;

    // ...
}

Creare una sessione Checkout per un abbonamento

Il flusso consigliato è Checkout anche per gli abbonamenti, così Stripe gestisce SCA e metodi di pagamento. Devi avere un Price ID (es. price_...) configurato su Stripe.

public function subscribe(Request $request)
{
    $user = $request->user();

    return $user
        ->newSubscription('default', 'price_1234567890')
        ->checkout([
            'success_url' => route('billing.success') . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('billing.cancel'),
        ]);
}

Una volta completato il pagamento, Cashier manterrà lo stato dell’abbonamento sincronizzato se riceve i webhooks corretti.

Gestione dello stato dell’abbonamento

// Attivo?
$user->subscribed('default');

// In prova?
$user->subscription('default')?->onTrial();

// Annullare a fine periodo
$user->subscription('default')->cancel();

// Riprendere se in grace period
$user->subscription('default')->resume();

Webhooks: implementazione robusta in Laravel

I webhooks sono il punto chiave per rendere “veri” i pagamenti e reagire a eventi asincroni: pagamenti completati, fallimenti, rinnovi, chargeback, rimborsi. Implementali con verifica della firma.

Endpoint webhook con verifica della firma

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Stripe\Webhook;

class StripeWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $payload = $request->getContent();
        $sigHeader = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook.secret');

        try {
            $event = Webhook::constructEvent($payload, $sigHeader, $secret);
        } catch (\UnexpectedValueException $e) {
            return response('Invalid payload', 400);
        } catch (\Stripe\Exception\SignatureVerificationException $e) {
            return response('Invalid signature', 400);
        }

        // Dispatch in coda per affidabilità e tempi rapidi
        match ($event->type) {
            'checkout.session.completed' => $this->onCheckoutCompleted($event->data->object),
            'payment_intent.succeeded'  => $this->onPaymentIntentSucceeded($event->data->object),
            'invoice.payment_failed'    => $this->onInvoicePaymentFailed($event->data->object),
            default => null,
        };

        return response('ok', 200);
    }

    protected function onCheckoutCompleted($session): void
    {
        // Esempio: crea/chiudi ordine, abilita licenza, invia email, ecc.
        // Usa $session->metadata per agganciare l’utente/ordine.
    }

    protected function onPaymentIntentSucceeded($intent): void
    {
        // Esempio: gestisci pagamenti custom basati su Payment Intents.
    }

    protected function onInvoicePaymentFailed($invoice): void
    {
        // Esempio: avvisa l’utente che il rinnovo abbonamento è fallito.
    }
}

Rotta webhook (senza auth, ma con firma):

use App\Http\Controllers\StripeWebhookController;

Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])
    ->name('stripe.webhook');

Idempotenza: evitare elaborazioni duplicate

Stripe può ritentare l’invio degli eventi. È quindi essenziale rendere l’elaborazione idempotente: salva l’ID dell’evento (es. evt_...) in tabella e ignora i duplicati.

<?php

// Migrazione esemplificativa:
Schema::create('stripe_events', function ($table) {
    $table->id();
    $table->string('event_id')->unique();
    $table->string('type');
    $table->timestamps();
});

// Nel controller:
if (\DB::table('stripe_events')->where('event_id', $event->id)->exists()) {
    return response('duplicate', 200);
}

\DB::table('stripe_events')->insert([
    'event_id' => $event->id,
    'type' => $event->type,
    'created_at' => now(),
    'updated_at' => now(),
]);

Rimborsi e gestione dispute

Per un rimborso, in genere rimborsi la charge o il payment_intent associato. Conserva sempre i riferimenti Stripe nel tuo database (es. payment_intent_id) per poter operare senza ambiguità.

$refund = $stripe->client()->refunds->create([
    'payment_intent' => $paymentIntentId,
    // 'amount' => 990, // rimborso parziale in centesimi, opzionale
]);

Ascolta via webhook eventi come charge.refunded e (se rilevante per te) charge.dispute.created, per aggiornare lo stato dell’ordine e notificare il team.

Testing locale: Stripe CLI e casi reali

In sviluppo, usa Stripe CLI per inoltrare webhooks alla tua app locale. È utile per simulare eventi senza dover completare sempre un pagamento reale.

stripe login
stripe listen --forward-to http://localhost:8000/stripe/webhook

Per testare i pagamenti, Stripe fornisce carte di test e scenari (SCA, fallimenti, rimborsi). Ricorda di usare sempre chiavi test in ambiente di sviluppo.

Best practice di sicurezza e affidabilità

  • Mai calcolare importi fidandoti del client: il backend deve determinare prezzo e quantità in modo autoritativo.
  • Usa HTTPS in produzione; imposta correttamente i domini consentiti e le callback URL.
  • Verifica sempre i webhooks tramite firma e implementa idempotenza.
  • Metti in coda l’elaborazione degli eventi: evita timeout e migliora resilienza (es. queue worker).
  • Log strutturati e tracciamento: salva riferimenti come checkout_session_id, payment_intent_id, customer_id.
  • Gestisci gli stati: i pagamenti non sono “solo succeeded/failed”. Considera requires_action, processing, canceled.

Checklist di messa in produzione

  1. Imposta chiavi live in variabili d’ambiente e ruotale se necessario.
  2. Configura i webhooks live su Stripe verso l’endpoint pubblico.
  3. Abilita i metodi di pagamento necessari (carte, wallet, bonifici, ecc.) e verifica i requisiti locali.
  4. Verifica fiscalità e ricevute: valuta Stripe Tax o integrazioni di fatturazione se richiesto.
  5. Monitora errori e ritardi della coda; prepara alert su eventi critici (pagamenti falliti, dispute).

Scelta rapida dell’approccio

  • Checkout: migliore per partire velocemente e ridurre il rischio; ottimo per pagamenti una tantum e abbonamenti.
  • Payment Intents + Elements: migliore se vuoi UX totalmente custom e hai bisogno di controllare ogni dettaglio.
  • Cashier: ideale per abbonamenti e gestione “Laravel-friendly” di customer e subscription lifecycle.

Con questi elementi hai una base solida per implementare pagamenti con Stripe in Laravel, mantenendo sicurezza, tracciabilità e resilienza. Il passo successivo è modellare ordini e piani nel tuo dominio applicativo (tabelle orders, payments, subscriptions) e farli avanzare di stato esclusivamente in risposta ai webhooks.

Torna su