michael-rubel/laravel-stripe-integration
A Laravel package that simplifies Stripe integration by providing configuration and convenient wrappers for common Stripe API workflows, helping you add payments, subscriptions, and webhook handling to your application with less boilerplate.
Install and wire up
composer require michael-rubel/laravel-stripe-integration:^2.0.1
php artisan vendor:publish --provider="MichaelRubel\StripeIntegration\StripeIntegrationServiceProvider"
Pin ^2.0.1 rather than floating. The 2.0.0 release shipped a broken payment_method parameter that was only corrected in the 2.0.1 patch — if you install loose and Composer resolves an older tag on a machine with a stale lockfile, you get the broken request shape.
The service provider is auto-discovered, so there is no manual registration step. The published config file is the single source of truth — read it before you read the source. It tells you exactly which env variable names are consumed (the package follows the usual STRIPE_KEY / STRIPE_SECRET conventions, but verify rather than assume) and whether the client is registered as a singleton in the container.
Set your credentials in .env, using test keys first:
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Then clear any cached config (php artisan config:clear) — a stale config cache is the most common "it works locally but not in staging" cause with this class of package.
Where to look first
config/stripe-integration.php (or similarly named) — the config keys and defaults. In 2.0.1 check specifically how the payment method setting is named and passed through.vendor/michael-rubel/laravel-stripe-integration/src — it is a thin wrapper, so you can read the whole thing in ten minutes. That is a feature: there is no hidden magic.First use case: a PaymentIntent
The canonical day-one flow is: create a PaymentIntent server-side, hand the client_secret to the browser, confirm with Stripe.js.
use Illuminate\Http\Request;
public function createIntent(Request $request)
{
$stripe = app(\Stripe\StripeClient::class); // or the package's bound client/contract
$intent = $stripe->paymentIntents->create([
'amount' => $request->integer('amount'), // cents
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => ['order_id' => $request->integer('order_id')],
], [
'idempotency_key' => 'order-'.$request->integer('order_id'),
]);
return response()->json(['client_secret' => $intent->client_secret]);
}
If the package exposes a facade or contract, prefer resolving that instead of StripeClient directly — it is the seam you will want later for testing and multi-account support.
Inject, don't call statics. The old \Stripe\Stripe::setApiKey(...) style mutates global state and makes tests order-dependent. Resolve the client (or the package contract) from the container and inject it into a constructor. Every pattern below assumes this.
Wrap each Stripe call in an application action. Don't let Stripe SDK calls leak into controllers, jobs, and models. Create small classes like CreatePaymentIntent, RefundCharge, CancelSubscription that take the client in their constructor and return your own DTO or value object. Benefits:
\Stripe\ imports.final class CreatePaymentIntent
{
public function __construct(private \Stripe\StripeClient $stripe) {}
public function __invoke(Order $order): \Stripe\PaymentIntent
{
return $this->stripe->paymentIntents->create([
'amount' => $order->total_cents,
'currency' => $order->currency,
'metadata' => ['order_id' => $order->id],
], ['idempotency_key' => "order-{$order->id}-intent"]);
}
}
Check the payment method parameter before you rely on it. The headline fix in 2.0.1 is the payment_method parameter. If you pass a payment method through the package's config or a wrapper method, confirm on your installed version that it reaches Stripe as payment_method (a single PaymentMethod ID / token) and not as payment_method_types (an array of strings). Mixing the two is a silent 400 at runtime, and it is exactly the class of bug that made 2.0.0 unusable for that path. Write an integration test that asserts the outgoing payload shape once, so a future minor bump can't regress it quietly.
Webhooks are the source of truth, not the redirect. Whatever happens on the success page, fulfilment should be driven by a verified webhook. The package gives you the client; you still write the endpoint.
public function __invoke(Request $request)
{
$event = \Stripe\Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
if (WebhookEvent::where('stripe_id', $event->id)->exists()) {
return response()->noContent(); // idempotent replay
}
WebhookEvent::create(['stripe_id' => $event->id, 'type' => $event->type]);
match ($event->type) {
'payment_intent.succeeded' => ProcessPaymentSucceeded::dispatch($event->data->object->id),
'charge.refunded' => ProcessRefund::dispatch($event->data->object->id),
default => null,
};
return response()->noContent();
}
Persist the event ID before dispatching, and make the job idempotent as well — Stripe retries aggressively and duplicates are the norm, not the exception.
Push slow work to queues. Anything beyond the initial API call (email receipts, entitlement updates, ERP sync) belongs in a job. The webhook endpoint should acknowledge in well under a second.
Multi-account / per-tenant keys. The singleton registration is convenient but assumes one set of credentials. If you need Connect accounts or per-tenant keys, construct new \Stripe\StripeClient($tenant->stripe_secret) on demand and scope it to the action, rather than reaching for the container binding.
Testing. Use Stripe's test mode with the standard test cards (4242…, 4000…0002 for declines, 4000…3155 for auth-required). For unit tests, bind a stub of the package contract in the container so no HTTP is made; assert on the arguments passed to your action classes rather than on Stripe SDK internals. For webhook tests, sign a synthetic payload with your test webhook secret and post it as raw JSON.
Integration tip: idempotency keys belong in your code, not Stripe's. Derive them from stable domain identifiers (order-{id}-intent), so a retried job or a user double-clicking "Pay" cannot create two charges.
Upgrade discipline matters here. 2.0.1 is a small patch: a dependabot bump of composer/composer (a dev-only tool, no runtime effect) and a fix for the payment_method parameter. There are no breaking changes, no new features, and no deprecations in this release — but the fix is a real behavioural correction, so treat >= 2.0.1 as your floor and do not inherit a 2.0.0 lockfile into a new environment.
This package is archived (no active feature development; releases are rare and patch-level). Treat it as a starting point, not a dependency you can set and forget. Pin the version, and re-audit it against the current stripe/stripe-php major version before shipping — API version pinning in the SDK changes between majors, and an old wrapper may assume an older request shape. If you need long-term support for subscriptions and billing state, Laravel Cashier or direct stripe/stripe-php is a safer bet.
It is a client wrapper, not a billing system. It does not model customers, subscriptions, invoices, or entitlements. You own the schema, the state machine, and the webhook handlers. Budget for that up front; the package saves you a config file and a singleton, not an integration.
Webhook signature verification needs the raw body. $request->getContent() must be unmodified. If you have middleware that re-encodes JSON, or you rely on $request->all() after php://input has been consumed, verification will fail with a confusing "No signatures found" error. Exclude the webhook route from CSRF and from any body-rewriting middleware, and confirm your proxy forwards the Stripe-Signature header untouched.
Webhook endpoint should bypass auth but not authorisation. Keep it in routes/api.php outside auth middleware, but still log the event IDs and alert on verification failures — a spike in 400s means either a rotated secret or someone probing the endpoint.
Amounts are integer minor units. 1000 = $10.00, not 10.00. Zero-decimal currencies (JPY, KRW) and three-decimal currencies (BHD, KWD) will bite you if you build a generic * 100 helper. Validate currency codes against Stripe's list.
Only the publishable key goes to the browser. It is easy to leak the secret when echoing config into a Blade view. Read the secret only in server-side code and prefer injecting it via the client binding rather than config() calls scattered around.
Don't mix API versions. The Stripe account dashboard has a default API version; the SDK pins its own. Objects returned by the SDK follow the SDK's version. If your team tests against the dashboard's version, event payloads in webhooks may still arrive at the account version — normalise in your handler and don't assume fields exist.
Debugging workflow. Use the Stripe CLI:
stripe login
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger payment_intent.succeeded
stripe listen prints the whsec_... you should use locally — it is not the same as the dashboard webhook secret. Log the full event JSON (redacting card data) behind a debug flag; Stripe's error messages are good but they reference their object model, and having the payload on hand saves round trips.
Useful extension points. Bind your own implementation of the package's contract in a service provider to add retries, structured logging, or metrics without touching call sites. If the package does not expose a contract you like, define your own interface, adapt it to the Stripe client, and stop depending on the package's public surface — that keeps the archived dependency at arm's length.
Config caching. The package reads from config, so php artisan config:cache in deployment is fine, but remember to clear it after any env change. A mismatched secret after a key rotation is almost always a cached config, not a bad key.
Pin stripe/stripe-php explicitly in your composer.json alongside this package. Otherwise a transitive update can change SDK behaviour under you, and the archived wrapper won't be updated to compensate.
How can I help you explore Laravel packages today?