cartalyst/stripe-laravel
Laravel integration for the Stripe PHP SDK, providing a service provider, configuration file, and a Stripe facade for convenient access to Stripe's API—customers, charges, subscriptions, webhooks, and more—from within your Laravel application.
Before you install, read composer.json in the package repo, not the README. The description still says "Laravel 8 integration" while v17.0.0 targets Laravel 12. The README is stale; the resolved constraint is the truth. If your app is below the declared Laravel major, you are on an older package major — check the changelog for the version that matches your framework rather than forcing a bump.
composer require cartalyst/stripe-laravel
php artisan vendor:publish --provider="Cartalyst\Stripe\Laravel\StripeServiceProvider"
The service provider is auto-discovered; the facade alias is Stripe → Cartalyst\Stripe\Laravel\Facades\Stripe. Confirm both in config/app.php on older Laravel, or bootstrap/providers.php / package discovery cache on 11+.
Where to look first, in this order:
config/stripe.php — the multi-connection shape (default + connections). This decides whether you are single-account or multi-account from day one. Adding connections later is a config edit but a code sweep if you hardcoded the default.vendor/cartalyst/stripe/src/Stripe.php (the parent package) — this is the actual API surface. It is a fluent resource proxy: customers(), charges(), subscriptions(), invoices(), plans(), coupons(), tokens(), transfers(). Skim the resource list before you write a single call; it tells you what exists and, more importantly, what doesn't.vendor/cartalyst/stripe-laravel/src/ — the provider bindings and the manager that resolves connections. Ten minutes here tells you what to type-hint and how connection() switching works, which no doc will tell you reliably.First use case — a customer + a charge, in tinker:
use Cartalyst\Stripe\Laravel\Facades\Stripe;
$customer = Stripe::customers()->create([
'email' => $user->email,
'source' => $request->input('stripe_token'), // token from Stripe.js, never raw PAN
]);
Stripe::charges()->create([
'amount' => 2500, // smallest currency unit
'currency' => 'usd',
'customer' => $customer['id'],
]);
Note the shape: resources take the resource identifier as a positional argument and the payload as an array. subscriptions()->create($customerId, [...]), customers()->update($id, [...]). This is the single most common first-hour mistake.
Never call the facade from controllers. The whole recurring cost of this package is that it sits on the payment path behind two upstreams (Laravel majors, stripe-php/API versions). Contain it:
interface BillingGateway
{
public function createCustomer(User $user, string $token): string;
public function charge(string $customerId, int $amount, string $currency, string $idempotencyKey): string;
}
final class StripeBillingGateway implements BillingGateway
{
public function __construct(private readonly \Cartalyst\Stripe\Stripe $stripe) {}
public function charge(string $customerId, int $amount, string $currency, string $key): string
{
$charge = $this->stripe->charges()->create([
'amount' => $amount,
'currency' => $currency,
'customer' => $customerId,
], ['Idempotency-Key' => $key]); // verify the header arg position in the resource signature
return $charge['id'];
}
}
Type-hint the underlying class or resolve the container key the provider actually registers — check the provider rather than guessing between stripe, stripe.manager, and Cartalyst\Stripe\Stripe::class. This one abstraction turns "swap to stripe-php directly, or to Cashier" into a contained refactor instead of a codebase sweep.
Multi-connection / multi-account. The package supports multiple Stripe accounts in config. Use it for marketplace splits, per-tenant keys, or a separate test account — not as a runtime switch you sprinkle through business logic. Resolve the connection once at the edge (middleware, job constructor) and pass the concrete client in:
$stripe = Stripe::connection('platform'); // confirm the method name in the facade/manager
Idempotency, always. The wrapper adds no retry, no idempotency store, no backoff. Derive the key from your own domain identity (order:{$order->uuid}:charge) so a queue retry or a duplicate click cannot double-charge. On ApiConnectionException (timeout, DNS, 5xx), assume the charge may have succeeded — retrieve by idempotency key or search by your metadata before retrying.
Webhooks. There is no webhook helper here. Build one controller:
public function __invoke(Request $request)
{
$event = \Stripe\Webhook::constructEvent(
$request->getContent(), // raw body, not $request->all()
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
if (Event::where('stripe_id', $event->id)->exists()) {
return response()->noContent(); // idempotent replay
}
// dispatch a job, persist the event id, return 200 fast
}
Exclude the route from CSRF (bootstrap/app.php → validateCsrfTokens(except: ['stripe/webhook']) on 11+, or VerifyCsrfToken::$except on older). Do not do the work inline; Stripe retries on non-2xx and you will build a retry storm against your own DB.
Persist Stripe IDs, never trust client-supplied ones. Store stripe_customer_id, stripe_subscription_id, stripe_payment_intent_id on your own models. Any id arriving in a request body is attacker-controlled until you retrieve() it and check customer matches the authenticated user.
Config-driven, env-backed credentials. Keys come from env() inside config/stripe.php only — never env() in application code, since php artisan config:cache will silently return null. In production, source them from your secrets manager into the deploy environment.
Testing pattern. There is no Stripe::fake(). Three options, in order of preference: (1) bind a fake BillingGateway in tests — this is why you made the interface; (2) run against Stripe test mode with a per-test customer and clean up; (3) point the SDK at a mock server if the underlying client allows a base-URL override — verify in vendor/ before relying on it, it is not a documented package feature.
The wrapper-of-a-wrapper ceiling. cartalyst/stripe-laravel wraps cartalyst/stripe, which wraps stripe/stripe-php. Newer Stripe surfaces — PaymentIntents/SetupIntents in full, Checkout Sessions, Billing Portal, Payment Methods API — may not have fluent resource methods. Check vendor/cartalyst/stripe/src/ first; if absent, drop to the raw SDK for that call rather than fighting the abstraction. Mixing is fine as long as both point at the same pinned API version.
Your API version is inherited by default. If version is null in config, the account default applies — and Stripe can change that default, silently altering charge and subscription semantics underneath you. Pin it explicitly in config, record it in the repo, and treat bumping it as a tested migration, not a config tweak.
Amounts are integers in the smallest currency unit, and not all currencies have two decimals. 2500 = $25.00 for USD, but JPY/KRW are zero-decimal — do not multiply by 100. Put the conversion in one place and unit-test it; this is the classic off-by-100 billing bug.
Card errors are not generic exceptions. Catch \Stripe\Exception\CardException separately from ApiErrorException, RateLimitException, and ApiConnectionException. Use getDeclineCode() for routing (retry vs. prompt for new card), but never render the raw Stripe message or getStripeCode() to end users or logs verbatim — log the Stripe request id for support correlation and keep PAN-adjacent data out of logs entirely.
Facade mocking on fluent chains is miserable. Stripe::shouldReceive('customers->create') works but breaks the moment you refactor. The BillingGateway interface is cheaper than the mock gymnastics and survives the eventual package swap.
Rate limits and test-mode ceilings. Test mode has stricter limits than live. RateLimitException needs exponential backoff with jitter; a naive retry(3) in a queue will make it worse. Also, queue workers and Octane hold the resolved connection in memory — if you switch connections per tenant, reset per job, or you will charge the wrong account.
Exception on boot vs. first call. Missing/invalid credentials typically surface on the first API call, not at container resolution. Do not assume a green health check means payments work — add one smoke test that lists something trivial (e.g. balance()->retrieve()) against the right connection.
Upgrade hygiene. Never composer update cartalyst/stripe-laravel blind on the payment path. Bump the constraint, review the lockfile diff (it will pull stripe-php with it), and run a smoke suite in test mode covering: create customer, charge, subscription create/prorate/cancel, refund, decline path, webhook verify. Keep the prior major resolvable so rollback is a one-line constraint revert. Also confirm the stripe-php major this pins doesn't conflict with any other service using the raw SDK — that coordination is the real compatibility risk, not the Laravel constraint.
Scope guardrail. Server-side only, tokens/sources from Stripe.js, Elements, or Checkout — never a form that posts a card number to your server. That keeps you in SAQ-A. This is a policy decision the package cannot enforce for you.
How can I help you explore Laravel packages today?