ricardoneudorfer / stripekit
A complete, production-ready Stripe integration toolkit for PHP. Handles customers, payment methods, payments, checkout, subscriptions, invoices, coupons, webhooks and Stripe Elements with built-in timezone handling.
Requires
- php: >=8.1
- ext-intl: *
- ext-json: *
- stripe/stripe-php: ^17.0
README
A complete, production-ready Stripe toolkit for PHP. StripeKit wraps the official stripe/stripe-php SDK with a simple, opinionated layer for customers, payment methods, payments, checkout, subscriptions, invoices, coupons and webhooks — so you can integrate Stripe into your own application without becoming a Stripe API expert.
You stay in full control of your database and UI. StripeKit only talks to Stripe: it creates and reads Stripe objects, normalizes the responses into clean, timezone-aware records, and — if you give it a storage adapter — persists that normalized state for you.
Why StripeKit
- One
init()call. Provide your secret key, chooseapiorelementsmode, optionally set a timezone, and every module is ready to use. - You choose the flow. In
apimode, payment and checkout calls return a hosted Stripe URL you redirect the customer to. Inelementsmode, they return aclientSecretyour own frontend uses to confirm the payment with Stripe Elements. Inbothmode, you decide per call. - Elements are fully optional. The browser-side helpers ship as a separate JS asset under
resources/js/stripekit-elements.js. Stripe Elements is inherently browser code, so it cannot run in PHP — StripeKit ships it as a companion frontend module you serve alongside your PHP backend. If you don't need Stripe Elements, you never have to include it. - Timezones handled correctly. Every timestamp StripeKit returns is first normalized to UTC (Stripe's native format), then — if you configured a timezone — converted once more into that timezone for display. Both values are always returned so you never have to do the math yourself.
- You own your data. StripeKit never requires a database. Pass an optional
storageadapter to persist customers, subscriptions, invoices, payments and payment methods in your own schema; without one, StripeKit still works, it just won't persist anything for you. - Covers the whole billing surface. Customers, payment methods, one-off payments, checkout (payment or subscription, with custom fields and coupons), subscriptions, invoices, coupons/promotion codes, and webhooks with automatic state sync.
Installation
composer require ricardoneudorfer/stripekit
stripe/stripe-php is installed automatically as a dependency.
If you plan to use Stripe Elements in the browser, serve the bundled asset at
vendor/ricardoneudorfer/stripekit/resources/js/stripekit-elements.js from your
public web root (or copy/symlink it into your own asset pipeline).
Quick start
<?php require 'vendor/autoload.php'; use StripeKit\StripeKit; $kit = StripeKit::init([ 'secretKey' => getenv('STRIPE_SECRET_KEY'), 'mode' => 'api', 'timezone' => 'Europe/Amsterdam', 'currency' => 'eur', ]); $payment = $kit->payments->create([ 'amount' => 2500, 'currency' => 'eur', 'email' => 'customer@example.com', 'description' => 'Pro plan', ]); echo $payment['hostedUrl'];
Every action StripeKit can perform is available under $kit-><module>-><action>(). Initialize StripeKit once and share the instance across your app (e.g. via a DI container or a singleton factory).
Initialization
StripeKit::init([ 'secretKey' => string, 'publishableKey' => string, // optional 'webhookSecret' => string, // optional 'mode' => 'api' | 'elements' | 'both', 'timezone' => string, // optional 'currency' => string, // optional 'successUrl' => string, // optional 'cancelUrl' => string, // optional 'storage' => StorageAdapter, // optional 'apiVersion' => string, // optional 'appInfo' => ['name' => string, 'version' => string, 'url' => string], // optional 'debug' => bool, // optional 'maxNetworkRetries' => int, // optional 'timeout' => int, // optional, milliseconds ]);
| Option | Required | Description |
|---|---|---|
secretKey |
Yes | Your Stripe secret key (sk_live_... / sk_test_...). Never expose this in the browser. |
mode |
Yes | 'api', 'elements', or 'both'. Explained below. |
publishableKey |
Only for elements/both |
Your Stripe publishable key. Not used server-side, but returned via $kit->toClientConfig() so your frontend can fetch it from your own backend instead of hardcoding it. |
webhookSecret |
Only if using $kit->webhooks |
Your endpoint's signing secret from the Stripe Dashboard. |
timezone |
No, defaults to "UTC" |
Any IANA timezone, e.g. "Europe/Amsterdam", "America/New_York". See Timezones. |
currency |
No, defaults to "usd" |
Default 3-letter ISO currency for calls that don't specify one. |
successUrl / cancelUrl |
No | Default redirect URLs used by hosted Checkout Sessions when not passed per call. |
storage |
No | A StorageAdapter implementation. See Storage adapter. |
If required options are missing or invalid, StripeKit::init() throws a ConfigurationError immediately, so misconfiguration is caught at boot time rather than mid-request.
Choosing a mode
This is the single most important decision StripeKit asks you to make, because it decides what your payment and checkout calls hand back to you:
mode: 'api'— StripeKit creates a Stripe-hosted Checkout Session and returnshostedUrl. You redirect the customer there; Stripe hosts the entire payment form. Nothing to build on your frontend.mode: 'elements'— StripeKit creates aPaymentIntent(orSetupIntent) and returnsclientSecret. You mount your own Stripe Elements form (using the optionalresources/js/stripekit-elements.jshelper, or your own code) and confirm the payment yourself. Full control over your UI.mode: 'both'— StripeKit defaults to the'api'behavior, but every call that creates a payment or checkout accepts amode(orflowOverride) argument to choose per call.
$kit->payments->create(['amount' => 1000, 'mode' => 'elements']); // works even if global mode is 'api', as long as it's 'both'
Timezones
Stripe stores every timestamp as a Unix epoch — which is UTC by definition. StripeKit always does the conversion in two explicit steps, never one:
- Normalize to UTC. The raw Unix timestamp is converted into an ISO-8601 UTC string. This is always available as the
...Utcfield (e.g.currentPeriodEndUtc,issuedAtUtc,paidAtUtc). - Convert to your configured timezone. If you set a
timezoneon init (anything other than"UTC", the default), that UTC value is converted once more into your chosen timezone and exposed as the matching...Localfield (e.g.currentPeriodEndLocal).
$kit = StripeKit::init(['secretKey' => $secretKey, 'mode' => 'api', 'timezone' => 'Asia/Tokyo']); $sub = $kit->subscriptions->retrieve('sub_123'); echo $sub['currentPeriodEndUtc']; // "2026-09-01T00:00:00.000Z" echo $sub['currentPeriodEndLocal']; // "2026-09-01T09:00:00" in Asia/Tokyo
If you don't set a timezone, both fields are UTC, and no double conversion happens. You can also use the exported helpers directly:
use StripeKit\Support\Timezone; Timezone::unixToTimezone(1735689600, 'Europe/Amsterdam'); Timezone::nowInTimezone('Europe/Amsterdam');
Storage adapter
StripeKit does not require a database, but every module that creates or reads Stripe state will call into an optional storage adapter if you provide one, so your own database always reflects reality without you writing that glue code yourself.
use StripeKit\Contracts\StorageAdapter; class MyStorageAdapter extends StorageAdapter { public function findUserByEmail(string $email): ?array { /* return ['id' => ..., 'stripeCustomerId' => ...] or null */ } public function findUserById(string|int $id): ?array { /* ... */ } public function saveCustomer(array $record): void { /* upsert into your users/customers table */ } public function saveSubscription(array $record): void { /* upsert into your subscriptions table */ } public function savePayment(array $record): void { /* upsert into your payments table */ } public function saveInvoice(array $record): void { /* upsert into your invoices table */ } public function savePaymentMethods(string|int $userId, array $records): void { /* replace the user's saved methods */ } public function saveCoupon(array $record): void { /* cache coupon/promo code metadata */ } public function markInvoiceDeleted(string $invoiceId): void { /* soft-delete */ } public function hasProcessedWebhookEvent(string $eventId): bool { /* idempotency check, recommended in production */ } public function markWebhookEventProcessed(string $eventId, string $type): void { /* ... */ } public function saveCheckoutSession(array $session): void { /* persist in-flight custom checkout sessions */ } public function getCheckoutSession(string $checkoutId): ?array { /* ... */ } } $kit = StripeKit::init(['secretKey' => $secretKey, 'mode' => 'api', 'storage' => new MyStorageAdapter()]);
Every method on the adapter is optional — override only what you need; StorageAdapter is an abstract class with safe no-op defaults. See examples/storage-adapter-postgres.php for a full PDO/Postgres-backed implementation.
Note on statelessness:
$kit->checkoutand$kit->webhooksfall back to in-process memory for checkout sessions and webhook idempotency when no storage adapter is supplied. That's fine for local development or a single-instance deployment, but for production deployments running multiple instances, providesaveCheckoutSession/getCheckoutSessionandhasProcessedWebhookEvent/markWebhookEventProcessedso state is shared correctly. StripeKit will log a warning whenever it falls back.
Modules
$kit->customers
$kit->customers->create(['email' => $email, 'name' => $name, 'phone' => null, 'address' => null, 'metadata' => null]); $kit->customers->findOrCreateByEmail($email); $kit->customers->retrieve($customerId); $kit->customers->update($customerId, ['name' => null, 'phone' => null, 'address' => null, 'defaultPaymentMethodId' => null, 'metadata' => null]); $kit->customers->delete($customerId); $kit->customers->list(['email' => null, 'limit' => null, 'startingAfter' => null]); $kit->customers->sync($customerId); // re-pull from Stripe and persist via storage
$kit->paymentMethods
$kit->paymentMethods->list($customerId); $kit->paymentMethods->attach(['paymentMethodId' => $id, 'customerId' => $customerId, 'setAsDefault' => false]); $kit->paymentMethods->detach($paymentMethodId); $kit->paymentMethods->setDefault($customerId, $paymentMethodId); $kit->paymentMethods->createSetupIntent(['customerId' => $customerId, 'usage' => null]); // to save a card for later, via Elements $kit->paymentMethods->sync($customerId);
$kit->payments
One-off payments (PaymentIntents), respecting the mode you configured.
$payment = $kit->payments->create([ 'amount' => 1999, // minor currency units (cents) 'currency' => 'usd', 'email' => 'customer@example.com', 'description' => 'One-time purchase', ]); // $payment['hostedUrl'] -> set when the resolved flow is 'api' // $payment['clientSecret'] -> set when the resolved flow is 'elements' $kit->payments->retrieve($paymentIntentId); $kit->payments->confirm($paymentIntentId, $paymentMethodId); $kit->payments->cancel($paymentIntentId); // Charge a card the customer already saved, without any customer interaction: $kit->payments->payWithSavedMethod([ 'customerId' => $customerId, 'paymentMethodId' => $paymentMethodId, 'amount' => 999, 'currency' => 'usd', ]);
$kit->checkout
The high-level module for building your own checkout flow: one-off payments or subscriptions, with optional custom fields and coupon codes, in either flow.
$checkout = $kit->checkout->create([ 'mode' => 'subscription', // or 'payment' 'priceId' => 'price_123', // required for subscriptions 'amount' => 4900, // required for one-off payments (and for subscriptions in 'elements' mode) 'email' => 'customer@example.com', 'couponCode' => 'WELCOME10', 'customFields' => [ ['key' => 'company_name', 'label' => 'Company name', 'required' => true], ], 'fieldValues' => ['company_name' => 'Acme BV'], ]); $kit->checkout->get($checkout['id']); $kit->checkout->submitFields($checkout['id'], ['company_name' => 'Acme BV']); $kit->checkout->applyCoupon(['checkoutId' => $checkout['id'], 'couponCode' => 'SAVE20', 'originalAmount' => 4900]); $kit->checkout->markComplete($checkout['id']);
$kit->subscriptions
$kit->subscriptions->create(['customerId' => $customerId, 'priceId' => $priceId, 'quantity' => null, 'trialPeriodDays' => null, 'collectionMethod' => null]); $kit->subscriptions->retrieve($subscriptionId); $kit->subscriptions->cancel(['subscriptionId' => $subscriptionId, 'atPeriodEnd' => true]); $kit->subscriptions->resume($subscriptionId); $kit->subscriptions->toggleCollectionMethod(['subscriptionId' => $subscriptionId, 'collectionMethod' => 'send_invoice', 'daysUntilDue' => 14]); $kit->subscriptions->updateFields(['subscriptionId' => $subscriptionId, 'fieldValues' => ['seats' => '10']]); $kit->subscriptions->applyPromotionCode($subscriptionId, $promotionCodeId); $kit->subscriptions->listByCustomer($customerId); $kit->subscriptions->sync($subscriptionId);
$kit->invoices
$kit->invoices->retrieve($invoiceId); $kit->invoices->listByCustomer($customerId, $status); $kit->invoices->listBySubscription($subscriptionId); $kit->invoices->payWithSavedMethod(['invoiceId' => $invoiceId, 'customerId' => $customerId, 'paymentMethodId' => $paymentMethodId]); $kit->invoices->voidInvoice($invoiceId); $kit->invoices->finalize($invoiceId); $kit->invoices->sync($invoiceId);
Invoices include a normalized lineItems array and both UTC and local timestamps for dueAt, paidAt and issuedAt.
$kit->coupons
$kit->coupons->create(['code' => 'SUMMER25', 'discountType' => 'percent', 'discountValue' => 25, 'duration' => 'once']); $kit->coupons->validate('SUMMER25'); // returns null if invalid, expired or inactive $kit->coupons->applyToSubscription($subscriptionId, $stripePromotionCodeId); $kit->coupons->list(); $kit->coupons->deactivate($stripePromotionCodeId);
$kit->webhooks
$result = $kit->webhooks->process([ 'payload' => $rawRequestBody, // string, must be the raw, unparsed body 'signature' => $_SERVER['HTTP_STRIPE_SIGNATURE'], 'handlers' => [ 'onPaymentSucceeded' => function ($paymentIntent) { /* ... */ }, 'onInvoicePaid' => function ($invoice) { /* ... */ }, 'onSubscriptionUpdated' => function ($subscription) { /* ... */ }, 'onSubscriptionDeleted' => function ($subscription) { /* ... */ }, ], ]);
$kit->webhooks->process() verifies the Stripe signature, deduplicates by event ID, and — unless you pass autoSync: false — automatically re-syncs the relevant object (payment, invoice, subscription, or payment methods) via your storage adapter before calling your handler. This means your database is always updated even if you don't implement a handler for a given event.
$kit->sync
A convenience module for pulling the full current state of a customer from Stripe on demand, e.g. after a support request or a manual reconciliation job:
$state = $kit->sync->everythingForCustomer($customerId, $userId); // $state['customer'], $state['subscriptions'], $state['invoices'], $state['paymentMethods']
Stripe Elements (optional, browser-side)
Serve resources/js/stripekit-elements.js from the package alongside your app and import it only on the pages that need it — it is plain browser JavaScript and never touches your PHP process.
<script type="module"> import { PaymentElementController } from '/vendor/ricardoneudorfer/stripekit/resources/js/stripekit-elements.js'; const controller = await PaymentElementController.create({ publishableKey, // fetch this from your backend via $kit->toClientConfig() clientSecret, // returned by $kit->payments->create() or $kit->checkout->create() in 'elements' mode appearance: { theme: 'stripe' }, }); controller.mount({ containerSelector: '#payment-element' }); const result = await controller.confirmPayment({ returnUrl: 'https://yourapp.com/billing/success', }); if (!result.success) { console.error(result.error); } </script>
See examples/elements-mode-frontend.html for a full working page, and examples/elements-mode-backend.php for the matching PHP backend.
Error handling
Every module throws typed exceptions you can catch and branch on:
use StripeKit\Exceptions\{ValidationError, NotFoundError, StripeOperationError, ConfigurationError}; try { $kit->payments->create(['amount' => 10, 'currency' => 'usd']); } catch (ValidationError $error) { // amount below Stripe's minimum, bad email, etc. $error->fieldErrors may be set. } catch (StripeOperationError $error) { // Stripe itself rejected the request; $error->cause holds the original Stripe error. }
Currency and amounts
All amounts are always in minor currency units (cents), matching Stripe's own convention, so there's never ambiguity between 19.99 and 1999. Helpers are available if you need to convert:
use StripeKit\Support\Money; Money::toMinorUnits(19.99, 'usd'); // 1999 Money::toMajorUnits(1999, 'usd'); // 19.99 Money::formatMoney(1999, 'usd'); // "$19.99"
Project structure
src/
StripeKit.php Main class, init() and module wiring
Support/ Timezone, money, validation, tokens, logging
Exceptions/ StripeKitError and its subclasses
Contracts/
StorageAdapter.php Optional persistence hook (abstract class, override what you need)
Modules/
BaseModule.php
CustomersModule.php
PaymentMethodsModule.php
PaymentsModule.php
CheckoutModule.php
SubscriptionsModule.php
InvoicesModule.php
CouponsModule.php
WebhooksModule.php
SyncModule.php
resources/js/
stripekit-elements.js Optional browser-side Stripe Elements helper
examples/
basic-api-mode.php
elements-mode-backend.php
elements-mode-frontend.html
webhook-handler.php
storage-adapter-postgres.php
docs/
TYPES.md Reference for every array shape used by the package
Requirements
- PHP >= 8.1
ext-intlandext-json(both installed automatically with most PHP distributions)stripe/stripe-php^17.0 (installed automatically via Composer)
License
MIT