The Stripe PHP SDK (stripe/stripe-php) wraps every REST endpoint behind typed classes, handles authentication, retries on transient errors, and deserializes responses into objects you can dot-walk through. But the official docs show each method in isolation, without the connective tissue: when to expand a nested object vs. fetch it separately, how pagination actually behaves on large datasets, what the response object is under the hood. This article fills that gap.
If you have not installed the SDK yet, the payment integration guide covers Composer setup, API keys, and your first PaymentIntent. Everything below assumes the SDK is loaded and ready.
StripeClient vs Stripe::setApiKey
The SDK has two calling conventions, and mixing them is the source of a surprising number of bugs.
Global static (legacy):
\Stripe\Stripe::setApiKey('sk_test_...');
$customer = \Stripe\Customer::create(['email' => '[email protected]']);
$intent = \Stripe\PaymentIntent::retrieve('pi_abc123');
Instance-based (recommended since v7.33):
$stripe = new \Stripe\StripeClient('sk_test_...');
$customer = $stripe->customers->create(['email' => '[email protected]']);
$intent = $stripe->paymentIntents->retrieve('pi_abc123');
The difference is not just cosmetic. setApiKey stores the key in a static property. Any code that runs in the same process shares the same key. That includes test suites, queue workers processing jobs for different Stripe accounts, and anything else that happens to live in the same memory. One job overwrites the key, the next job sends a request with the wrong credentials, and you get “No such payment_intent” with no indication that the key was the problem.
StripeClient keeps the key on the instance. Two instances can coexist with different keys in the same process. In a Laravel queue worker handling Connect accounts, that difference is the only thing standing between you and cross-account data leaks.
// Connect: operating on behalf of a connected account
$stripe = new \Stripe\StripeClient([
'api_key' => 'sk_test_platform_key',
'stripe_account' => 'acct_connected_123',
]);
For new code, always use StripeClient. The static pattern still works and Stripe has not announced a removal date, but new API endpoints ship only with service-based methods on the client. If you have a legacy codebase that calls Stripe::setApiKey, migrating takes minutes: instantiate a client, then search-and-replace \Stripe\Customer::create( with $stripe->customers->create( and so on. The argument arrays stay the same.
Authentication and API keys
Stripe gives you two key pairs, one for test mode and one for live:
| Key prefix | Purpose | Where it belongs |
|---|---|---|
sk_test_ | Secret key, test mode | Server only, env variable |
sk_live_ | Secret key, live mode | Server only, env variable |
pk_test_ | Publishable key, test mode | Browser (Stripe.js) |
pk_live_ | Publishable key, live mode | Browser (Stripe.js) |
Secret keys authenticate every server-side API call. Publishable keys are safe to expose in client-side JavaScript; they can only confirm payments and create tokens, not read or write account data.
Do Stripe API keys expire? No. Keys live until you roll them. Rolling generates a new key and gives you a grace window (24 hours for secret keys, configurable) during which both the old and new key work. After the window closes, the old key stops authenticating. Roll keys from the Dashboard under Developers > API keys.
Restricted keys are a third kind: you create them in the Dashboard with specific permissions (read charges, write customers, nothing else). Use them for microservices and third-party integrations where handing out a full secret key is too broad.
Keeping keys out of source control
The one rule: keys in environment variables, never in PHP files. A leaked sk_live_ key gives full access to your Stripe account.
// Good: read from env at boot
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
// Bad: hardcoded, will end up in git
$stripe = new \Stripe\StripeClient('sk_live_actualKeyHere');
In Laravel, store them in .env and pull through config/services.php:
// config/services.php
'stripe' => [
'secret' => env('STRIPE_SECRET_KEY'),
],
// anywhere in the app
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
The Laravel integration guide covers the full Service Provider setup, including singleton binding and test swaps.
Making API calls: the method pattern
Every resource follows the same shape. Methods map to HTTP verbs:
| SDK method | HTTP | Example |
|---|---|---|
->create([...]) | POST | Create a Customer |
->retrieve('id') | GET | Fetch one PaymentIntent |
->update('id', [...]) | POST | Update a Subscription |
->delete('id') | DELETE | Delete a coupon |
->all([...]) | GET | List charges |
The first argument to retrieve, update, and delete is always the object ID as a plain string. Create and list take an associative array of parameters. That signature is consistent across every resource:
$stripe = new \Stripe\StripeClient('sk_test_...');
// Create
$customer = $stripe->customers->create([
'email' => '[email protected]',
'name' => 'Jane Doe',
'metadata' => ['internal_id' => '42'],
]);
// Retrieve
$customer = $stripe->customers->retrieve('cus_abc123');
// Update
$stripe->customers->update('cus_abc123', [
'name' => 'Jane Smith',
]);
// Delete
$stripe->customers->delete('cus_abc123');
// List
$customers = $stripe->customers->all(['limit' => 10]);
Passing extra parameters alongside the ID
retrieve takes the ID and an optional params array. API parameters like expand go in that array:
$intent = $stripe->paymentIntents->retrieve(
'pi_abc123',
['expand' => ['customer', 'payment_method']]
);
update takes the ID, a params array for the fields to change, and an optional third array for request-level options (idempotency key, Stripe-Account header). expand is a regular API parameter, so it goes in the params array alongside your data:
$stripe->paymentIntents->update('pi_abc123', [
'metadata' => ['order_id' => '99'],
'expand' => ['customer'],
]);
// Request-level options go in the third argument
$stripe->paymentIntents->update(
'pi_abc123',
['metadata' => ['order_id' => '99']],
['idempotency_key' => 'update_pi_abc123_v2']
);
Expanding nested objects
By default, the API returns related objects as bare IDs. A PaymentIntent’s customer field comes back as "cus_abc123", not the full Customer object. Expand tells Stripe to inline the full object in the response so you skip the second API call.
$intent = $stripe->paymentIntents->retrieve('pi_abc123', [
'expand' => ['customer', 'payment_method'],
]);
// Now these are full objects, not strings
echo $intent->customer->email;
echo $intent->payment_method->card->last4;
How deep can you go?
Four levels of nesting, and no more than 10 expansions per request. Dot-separated paths walk the chain:
$charge = $stripe->charges->retrieve('ch_abc', [
'expand' => ['payment_intent.customer.default_source'],
]);
That expands the PaymentIntent inside the Charge, the Customer inside the PaymentIntent, and the default source on the Customer. Going deeper than four levels or requesting more than 10 expansions in a single call returns an error.
Expand on list calls
When listing objects, prefix expanded fields with data.:
$charges = $stripe->charges->all([
'limit' => 50,
'expand' => ['data.customer', 'data.balance_transaction'],
]);
foreach ($charges->data as $charge) {
echo $charge->customer->email; // expanded
echo $charge->balance_transaction->fee; // expanded
}
Performance note: expanding multiple fields on a list call with a high limit makes the response significantly larger and slower. Stripe fetches each expanded object internally. If you need one field from a related object on 100 items, consider whether a separate targeted API call would be cheaper overall.
Expand on create
Expand works on create too. Useful when you need the full object that gets auto-generated:
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [['price' => 'price_abc', 'quantity' => 1]],
'success_url' => 'https://example.com/thanks',
'expand' => ['payment_intent'],
]);
// payment_intent is the full object, not just pi_xxx
echo $session->payment_intent->status;
Pagination
List endpoints return at most 100 objects per call (default 10). Stripe uses cursor-based pagination: you pass the ID of the last object you received, and the API returns the next page starting after that cursor.
Manual pagination
$hasMore = true;
$startingAfter = null;
while ($hasMore) {
$params = ['limit' => 100];
if (null !== $startingAfter) {
$params['starting_after'] = $startingAfter;
}
$charges = $stripe->charges->all($params);
foreach ($charges->data as $charge) {
processCharge($charge);
}
$hasMore = $charges->has_more;
if ($charges->data) {
$startingAfter = end($charges->data)->id;
}
}
Auto-pagination
The SDK provides autoPagingIterator() which handles the cursor logic internally:
$charges = $stripe->charges->all(['limit' => 100]);
foreach ($charges->autoPagingIterator() as $charge) {
// Iterates through ALL charges, fetching new pages automatically
processCharge($charge);
}
Under the hood, autoPagingIterator makes a new API call each time the current page runs out. The limit parameter controls page size, not total count. With limit => 100, each HTTP request fetches 100 objects; the iterator keeps going until has_more is false.
There is no built-in way to stop after N total objects. If you only need the first 500 charges, add a counter:
$count = 0;
foreach ($charges->autoPagingIterator() as $charge) {
processCharge($charge);
if (++$count >= 500) break;
}
Filtering lists
Most list endpoints accept filters that narrow results server-side. Filtering before pagination is always preferable to fetching everything and filtering in PHP:
// Charges in the last 7 days
$charges = $stripe->charges->all([
'limit' => 100,
'created' => ['gte' => strtotime('-7 days')],
]);
// A specific customer's subscriptions
$subs = $stripe->subscriptions->all([
'customer' => 'cus_abc123',
'status' => 'active',
'limit' => 100,
]);
The created filter accepts gt, gte, lt, lte as keys with Unix timestamps.
Response objects
Every API response comes back as a StripeObject (or a subclass like Customer, PaymentIntent, etc.). These objects behave like a hybrid: you can access fields as properties ($customer->email) or as array keys ($customer['email']), but they are neither plain objects nor plain arrays.
Converting to JSON
The most common gotcha: json_encode($stripeObject) works, but it serializes only the public data. If you need the raw API response as a JSON string:
$customer = $stripe->customers->retrieve('cus_abc123');
// Works: serializes the object's data fields
$json = json_encode($customer);
// Also works: toArray() first, then encode
$array = $customer->toArray();
$json = json_encode($array, JSON_PRETTY_PRINT);
toArray() is the safer bet for persistence. It returns a plain associative array without any SDK internals. If you store Stripe responses in a database column or cache, use toArray() on retrieval and json_encode the result.
Checking for null fields
Stripe returns null for fields that were not set. The SDK preserves this, so $customer->phone is null rather than undefined. Use null coalescing:
$phone = $customer->phone ?? 'no phone on file';
The lastResponse property
Every StripeObject carries lastResponse, which holds the raw HTTP response from the API call that produced it:
$customer = $stripe->customers->retrieve('cus_abc123');
$httpStatus = $customer->lastResponse->code; // 200
$requestId = $customer->lastResponse->headers['Request-Id'];
$rawBody = $customer->lastResponse->body; // JSON string
Request-Id is essential for Stripe support tickets. Log it alongside every API call in production and include it when filing a support request.
Test cards and test tokens
Test mode is a full sandbox. No money moves, no card networks are contacted. But test-mode objects are real API objects with real IDs, and every feature works the same way as live mode, including webhooks, 3DS, disputes, and refunds.
Card numbers for Stripe.js / Elements
When your frontend collects card details through Stripe.js, use these numbers:
| Number | Brand | Behavior |
|---|---|---|
4242 4242 4242 4242 | Visa | Succeeds |
5555 5555 5555 4444 | Mastercard | Succeeds |
3782 822463 10005 | Amex | Succeeds |
4000 0025 0000 3155 | Visa | Requires 3DS authentication |
4000 0000 0000 9995 | Visa | Declined: insufficient_funds |
4000 0000 0000 0002 | Visa | Declined: generic_decline |
4000 0000 0000 0069 | Visa | Declined: expired_card |
4000 0000 0000 0127 | Visa | Declined: incorrect_cvc |
Use any future date for expiry and any 3 digits for CVC (4 for Amex).
PaymentMethod tokens for server-side tests
When testing server-side logic without a frontend, attach pre-built test PaymentMethods directly:
$stripe = new \Stripe\StripeClient('sk_test_...');
$intent = $stripe->paymentIntents->create([
'amount' => 2000,
'currency' => 'usd',
'payment_method' => 'pm_card_visa',
'confirm' => true,
'automatic_payment_methods' => [
'enabled' => true,
'allow_redirects' => 'never',
],
]);
echo $intent->status; // "succeeded"
The pm_card_* tokens skip the Stripe.js step entirely. Useful for integration tests, webhook pipeline testing, and CI.
| Token | Simulates |
|---|---|
pm_card_visa | Successful Visa payment |
pm_card_mastercard | Successful Mastercard |
pm_card_visa_debit | Visa debit card |
pm_card_chargeDeclined | generic_decline |
pm_card_chargeDeclinedInsufficientFunds | insufficient_funds |
pm_card_chargeDeclinedExpiredCard | expired_card |
pm_card_authenticationRequired | Requires 3DS |
The legacy tok_visa / tok_chargeDeclined format still works through an internal conversion, but pm_card_* is the current recommended form. Use pm_card_* for PaymentIntents and tok_* only if you are dealing with the legacy Charges API for some reason.
For the full decline code reference and how to handle each one in your code, see the errors guide.
API versioning
Stripe’s API evolves through dated versions like 2026-03-25.dahlia. Each major version can change response shapes, rename fields, or alter default behaviors. Your code depends on a specific version, and Stripe guarantees backwards compatibility within that version.
How the SDK pins versions
The SDK pins its API version to whatever was current when that SDK release was built. Your Dashboard account has a separate default version. Requests from the SDK use the SDK’s pinned version, not the Dashboard one.
// Check what version your SDK is using
echo \Stripe\Stripe::getApiVersion();
// e.g., "2026-03-25.dahlia"
Overriding the version
You can force a specific API version per-client or per-request:
// Per-client
$stripe = new \Stripe\StripeClient([
'api_key' => 'sk_test_...',
'stripe_version' => '2024-12-18.acacia',
]);
// Per-request (with the global pattern)
\Stripe\Stripe::setApiKey('sk_test_...');
$customer = \Stripe\Customer::create(
['email' => '[email protected]'],
['stripe_version' => '2024-12-18.acacia']
);
Override sparingly. Running one API call at a different version than the rest of your code means response shapes may differ across your codebase, which leads to subtle bugs.
Webhooks and versions
Webhook events are delivered in the API version of the endpoint that created them, not in the version your SDK uses. If your webhook endpoint is configured in the Dashboard with version X, but your SDK expects version Y, the event structure might not match what your code expects.
Keep them aligned: after upgrading the SDK and testing, bump the webhook endpoint version in the Dashboard to match. The webhooks guide covers this in the context of signature verification.
Idempotency
Stripe supports idempotency keys on all POST requests. Pass a unique key, and if the same request fires twice (network retry, user double-click, webhook redelivery), Stripe returns the same result without creating a duplicate object.
$stripe = new \Stripe\StripeClient('sk_test_...');
$intent = $stripe->paymentIntents->create([
'amount' => 5000,
'currency' => 'usd',
], [
'idempotency_key' => 'order_42_payment_attempt_1',
]);
The key is scoped to the request. Stripe stores the result for 24 hours; a second request with the same key and the same parameters returns the cached response. A second request with the same key but different parameters returns a 400 error.
What makes a good key: your domain identifier plus the intent. order_{id}_payment_{attempt} works. A UUID works too but is harder to debug. Avoid timestamps alone; two requests in the same millisecond with the same timestamp create the problem idempotency is supposed to prevent.
Where idempotency keys matter most
- PaymentIntent creation. A network timeout after Stripe processed the request but before your server received the response. Without a key, you retry and create a second payment.
- Refunds. Same scenario. Double refund is worse than double charge because the customer won’t complain and you won’t notice until reconciliation.
- Webhooks. Your handler should be idempotent by design (check if the order is already marked paid before marking it again). The idempotency key on the API call protects the outbound side; handler idempotency protects the inbound side.
Exception hierarchy
The SDK throws typed exceptions for every API error. The class hierarchy looks like this:
\Stripe\Exception\ApiErrorException (abstract base)
├── CardException // 402 - card declined, insufficient funds, etc.
├── InvalidRequestException // 400 - missing params, bad ID, wrong mode
├── AuthenticationException // 401 - bad API key
├── ApiConnectionException // network failure, timeout, DNS
├── IdempotencyException // conflicting idempotency key
├── PermissionException // 403 - restricted key, insufficient permissions
├── RateLimitException // 429 - too many requests
├── UnknownApiErrorException // anything else from the API
└── SignatureVerificationException // webhook signature mismatch (not a subclass)
SignatureVerificationException does not extend ApiErrorException because it comes from local signature verification, not from an API call. Catching ApiErrorException in a blanket handler will miss it.
Order matters. CardException extends ApiErrorException, so if ApiErrorException appears first in your catch chain, the CardException block never runs:
try {
$intent = $stripe->paymentIntents->create([...]);
} catch (\Stripe\Exception\CardException $e) {
// Must come BEFORE ApiErrorException
$decline = $e->getError()->decline_code;
log("Card declined: {$decline}");
} catch (\Stripe\Exception\RateLimitException $e) {
// 429: back off and retry
sleep(2);
} catch (\Stripe\Exception\InvalidRequestException $e) {
// Bad params, wrong mode, missing fields
log("Invalid request: " . $e->getMessage());
} catch (\Stripe\Exception\ApiErrorException $e) {
// Catch-all for everything else from the API
log("Stripe error: " . $e->getMessage());
}
The full breakdown of error types, decline codes, and recovery strategies is in the errors guide.
Metadata
Every major Stripe object accepts a metadata hash: up to 50 keys, each key up to 40 characters, each value up to 500 characters. Metadata is your bridge between Stripe’s world and yours.
$intent = $stripe->paymentIntents->create([
'amount' => 7500,
'currency' => 'eur',
'metadata' => [
'order_id' => '1042',
'campaign' => 'summer_sale',
'internal_id' => 'usr_887',
],
]);
Metadata travels with the object through its lifecycle. When the PaymentIntent succeeds and fires a webhook event, $event->data->object->metadata->order_id gives you "1042". That is how the webhook handler knows which order to fulfill without querying your database by amount or timestamp.
Metadata does not propagate between objects
This trips people up regularly. Metadata on a Checkout Session does not automatically copy to the PaymentIntent it creates. If your webhook handler listens for payment_intent.succeeded and reads metadata there, it will be empty unless you explicitly set payment_intent_data.metadata when creating the Session:
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [['price' => 'price_abc', 'quantity' => 1]],
'success_url' => 'https://example.com/thanks',
'metadata' => ['order_id' => '42'], // lives on the Session
'payment_intent_data' => [
'metadata' => ['order_id' => '42'], // also lives on the PaymentIntent
],
]);
Or skip the duplication and listen for checkout.session.completed instead of payment_intent.succeeded. The checkout guide covers this in detail.
Logging and debugging
Request-Id
Every API response includes a Request-Id header. Log it:
$customer = $stripe->customers->create(['email' => '[email protected]']);
$requestId = $customer->lastResponse->headers['Request-Id'];
error_log("Stripe request: {$requestId}");
When something goes wrong in production and you open a Stripe support ticket, the first thing they ask for is the Request-Id. Without it, debugging is a guessing game on both sides.
SDK-level logging
The SDK can log every HTTP request and response. It expects a PSR-3 logger (the psr/log package, which most frameworks already pull in; standalone scripts need composer require psr/log). In development, attach a logger to trace what goes over the wire:
\Stripe\Stripe::setLogger(new class extends \Psr\Log\AbstractLogger {
public function log($level, \Stringable|string $message, array $context = []): void
{
error_log("[Stripe {$level}] {$message}");
}
});
In production, set the log level to error or disable logging entirely. Full request/response logging includes card fingerprints and customer emails, which you probably do not want in a log file.
Common patterns
Check if a customer already exists before creating
$existing = $stripe->customers->search([
'query' => "email:'[email protected]'",
]);
if ($existing->data) {
$customer = $existing->data[0];
} else {
$customer = $stripe->customers->create([
'email' => '[email protected]',
]);
}
Note: the Search API is eventually consistent. A customer created one second ago may not appear in search results yet. For real-time lookups, store the Stripe customer ID in your own database and retrieve by ID.
Update the default payment method
$stripe->customers->update('cus_abc123', [
'invoice_settings' => [
'default_payment_method' => 'pm_newCard456',
],
]);
The old default_source field still works for legacy integrations but invoice_settings.default_payment_method is the current path. Subscriptions and invoices pull from this field when charging.
Retrieve a charge with fee breakdown
$charge = $stripe->charges->retrieve('ch_abc', [
'expand' => ['balance_transaction'],
]);
$fee = $charge->balance_transaction->fee; // total fee in cents
$net = $charge->balance_transaction->net; // amount minus fees
$details = $charge->balance_transaction->fee_details; // itemized: stripe fee, tax, etc.
The balance_transaction is where Stripe’s processing fee lives. It is not available on the Charge or PaymentIntent directly. The pricing guide covers fee logic in depth.
Does Stripe support PHP?
Yes. stripe/stripe-php is a first-party SDK maintained by Stripe’s own team, not a community wrapper. It tracks API changes within days of release, ships typed exceptions for every error category, and auto-paginates list results. Install via Composer (composer require stripe/stripe-php), include vendor/autoload.php, and every class in the \Stripe namespace is available. If Composer is not an option (shared hosting, legacy setup), the SDK ships an init.php loader you can require instead of the Composer autoloader. The payment integration guide covers both paths.
Is the Stripe API open source?
The API itself is proprietary, but the PHP SDK is open source under the MIT license. The source lives at github.com/stripe/stripe-php. You can read every line of code, file issues, and submit pull requests. The SDK is the HTTP client; the API behind it is a closed service.
What is a Stripe SDK?
An SDK (Software Development Kit) in this context is a PHP library that handles the HTTP plumbing of talking to Stripe’s REST API. Instead of building curl requests manually, setting headers, parsing JSON responses, and handling errors, the SDK gives you $stripe->customers->create([...]) and returns a typed object. Under the hood it still makes HTTPS calls, but the SDK manages authentication, serialization, retries, and error handling.
Stripe publishes official SDKs for PHP, Python, Ruby, Node.js, Java, Go, and .NET. The PHP SDK is maintained by Stripe directly, not by the community. Updates track API changes within days. The reason the Stripe API gets so much praise from developers is consistency: every resource follows the same CRUD pattern, every error returns a structured JSON body with a machine-readable code, and the test mode is a full replica of production. The SDK inherits that consistency and adds PHP-native conveniences on top.
What next
- First payment from scratch: Stripe payment integration in PHP
- Pre-built checkout forms: Stripe Checkout Sessions
- Handling payment events: Stripe webhooks in PHP
- Recurring billing: Stripe subscriptions in PHP
- Error recovery and decline codes: Stripe PHP errors
- Fee calculation in code: Stripe pricing and fees
- Using the SDK inside Laravel: Stripe in Laravel
Spotted something inaccurate on this page?
Report an error