cartalyst/stripe
Cartalyst Stripe is a PHP/Laravel wrapper around the Stripe API, offering a fluent, object-oriented SDK for charges, customers, subscriptions, invoices, coupons, and webhooks. It streamlines payment integration with clean syntax and Laravel support.
Three commands and one env var to a working call.
composer require cartalyst/stripe
php artisan vendor:publish --provider="Cartalyst\Stripe\Laravel\StripeServiceProvider"
Set STRIPE_SECRET in .env. The package ships a service provider and a Stripe facade alias; on Laravel 5.5+ auto-discovery should register both, but the last release is from 2021, so verify the discovery manifest actually resolves before assuming it. If it doesn't, register Cartalyst\Stripe\Laravel\StripeServiceProvider::class in config/app.php and alias Cartalyst\Stripe\Laravel\Facades\Stripe.
Where to look first, in this order:
config/stripe.php — the published config. It has a default profile name and a profiles array keyed by name. Multi-account support lives here, not in code.Cartalyst\Stripe\Laravel\Facades\Stripe — the facade root. It resolves a Cartalyst\Stripe\Stripe instance whose methods are resource factories: customers(), charges(), paymentIntents(), subscriptions(), invoices(), refunds(), tokens(), plans(), coupons(). Skim src/Api/ once; class names map one-to-one onto Stripe REST endpoints.POST /v1/..., reading src/Api/Customers.php tells you exactly which parameters pass through and which are transformed. This is faster than the README for edge cases.First use case: prove the wiring end-to-end in test mode with a customer + PaymentIntent. Do not start with subscriptions — the failure modes multiply and you won't know whether the problem is config, the wrapper, or your subscription model. A PaymentIntent round-trip validates config, API version, and response shape in one go. Refunds became PaymentIntent-aware only in v2.4.6, so this also tells you which refund semantics your installed version implements.
use Cartalyst\Stripe\Laravel\Facades\Stripe;
$customer = Stripe::customers()->create([
'email' => $user->email,
'description' => "user:{$user->id}",
]);
$intent = Stripe::paymentIntents()->create([
'amount' => 1999, // minor units — $19.99
'currency' => 'usd',
'customer' => $customer['id'],
'payment_method' => $request->payment_method_id,
'confirmation_method' => 'manual',
'confirm' => true,
]);
Note the bracket access: responses are plain PHP arrays, not \Stripe\* objects. That is the single biggest day-to-day difference from the official SDK and it shapes everything below.
Put one internal interface in front of the facade on day one. Never call Stripe:: from a controller or an Eloquent model. This is both good design and your migration insurance, given the release history.
interface BillingGateway
{
public function createCustomer(User $user): array;
public function charge(int $amount, string $currency, string $pmId, string $customerId): array;
public function refund(string $paymentIntentId, ?int $amount = null): array;
}
Shape the interface around your operations, not around Stripe's resource tree. Implement it with Stripe::… today; swap to stripe/stripe-php or Cashier later without touching call sites.
Multi-profile is the package's best genuine advantage over hand-rolling.
// config/stripe.php
'default' => 'main',
'profiles' => [
'main' => ['api_key' => env('STRIPE_SECRET'), 'api_version' => '2020-08-27'],
'eu' => ['api_key' => env('STRIPE_SECRET_EU'), 'api_version' => '2020-08-27'],
],
Stripe::profile('eu')->customers()->create([...]);
Pin api_version explicitly. If you rely on the account default, a dashboard-side version bump silently changes response shapes under a frozen client.
Wrap mutating calls in queued jobs with a stable idempotency key. Charges, refunds, and subscription mutations belong off the request thread. Pass an idempotency key derived from a domain identifier — "charge:{$order->id}", "refund:{$order->id}" — not a fresh UUID per attempt, or retries and queue redeliveries will charge twice.
Stripe::charges()->create([
'amount' => $amount,
'currency' => $currency,
'customer' => $customerId,
'source' => $token,
], ['Idempotency-Key' => "charge:{$order->id}"]);
Webhooks get their own controller and their own job. Verify the signature first, persist the event ID, dedupe on it, then dispatch. The package gives you no \Stripe\Webhook::constructEvent, so either pull stripe/stripe-php in just for verification, or compute the HMAC yourself: split the Stripe-Signature header into t and v1, build ${t}.${body}, HMAC-SHA256 with your signing secret, compare with hash_equals. Do not json_decode before verifying.
Route::post('/stripe/webhook', StripeWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Handlers should be idempotent and should re-fetch the object by ID from Stripe (Stripe::paymentIntents()->retrieve($id)) rather than trusting the event payload. Event schemas drift across API versions and a 2021-pinned wrapper won't track them.
Store IDs, not payloads. Persist cus_…, pi_…, sub_…, in_… on your own models alongside a stripe_account / profile column for multi-profile installs. Treat your database as an index into Stripe, not a copy of it. When you need current state, query Stripe.
Centralise amount handling. Write one Money::toMinor(int $dollars, string $currency): int helper and use it everywhere. The zero-decimal currency list is a maintenance hazard that belongs in exactly one place.
Contract-test the boundary. Because the package is frozen, your tests are the only thing stopping API drift from reaching production. Maintain a test-mode suite covering: customer create, PaymentIntent create/confirm, full and partial refund, subscription create/cancel, and webhook signature rejection on a tampered body. Fake BillingGateway for unit tests; hit Stripe test mode in a nightly job.
Testing hooks. The facade resolves a container singleton named stripe. Bind a fake in TestCase::setUp() or override config(['stripe.profiles.main.api_key' => 'sk_test_…']) per test. A fake BillingGateway in the container is usually cleaner than faking the Stripe wrapper itself.
"Full Stripe SDK coverage" is marketing, not architecture. This package does not wrap stripe/stripe-php. It talks to Stripe over Guzzle with its own resource classes. You get arrays back and you cannot mix \Stripe\ objects into the same call flow. If you need \Stripe\Webhook, \Stripe\Util\, or a payment-method type added after 2021, you are running two client stacks side by side — plan for it explicitly.
The release date is the risk, not the API. Last release: 2021-10-09. Before committing on a new project, in a throwaway branch: confirm Composer resolves against your PHP/Laravel versions, confirm the transitively-locked Guzzle isn't CVE-flagged, and confirm the target api_version is still accepted by Stripe. If any of those fail, "adoption" is really a rewrite with no intermediate state — budget accordingly.
Amounts are minor units, and zero-decimal currencies will bite you. 1999 means $19.99 in USD but ¥1999 in JPY. Stripe rejects a non-integer amount for zero-decimal currencies, so the bug surfaces as a validation error in production for the one region you didn't test.
Catch the package's exceptions, not Stripe's. Every error surfaces as a subclass of Cartalyst\Stripe\Exception\StripeException — card declines, invalid requests, auth failures. Register a Handler entry so they never render as 500s in a JSON API, log the Stripe error type and decline code internally, and return a generic customer-facing message. Never leak the raw Stripe message into an API response.
Refund semantics changed in 2.4.6. Refunds keyed off a legacy charge and refunds keyed off a payment_intent are different call shapes and were not both supported before that release. grep -rn 'refunds()->create' for every call site and confirm which identifier each passes. This is exactly where a silent version bump misbehaves.
No billing logic ships with this. No proration engine, no dunning, no trial state machine, no tax, no usage rating, no entitlements. Subscriptions are CRUD over Stripe's objects; everything above that is yours to build. Budget for dunning and proration explicitly rather than discovering them in week three.
Debugging: log the request, not the key. If you attach a Guzzle middleware for logging, strip the Authorization header before it hits the log sink. For debugging response-shape surprises, log the Stripe-Version header from the response — it tells you which API version Stripe actually applied, which is often more useful than the error message.
Extension points. The facade resolves the stripe container binding, so you can $this->app->bind('stripe', fn () => new YourSubclass(...)) to add per-request logging, custom retry policy, or even per-method delegation to the official SDK as part of a gradual migration. That binding is also the cleanest seam for injecting a test double.
Pagination is page-based, not auto-paginating. ->all(['limit' => 100]) returns one page plus has_more. Loop with starting_after yourself; don't assume the wrapper hides it.
How can I help you explore Laravel packages today?