Stripe Pricing Tables with Laravel — Embed Checkout Without Custom UI
Every SaaS pricing page needs the same things: plan cards, a billing period toggle, promo code support. Building that in Blade — and keeping it in sync with your Stripe products — is tedious. Stripe's Pricing Table does all of it with a single embeddable web component you configure in the dashboard. Here's how to wire it into a Laravel app with proper user tracking.
What is the Stripe Pricing Table?
The Pricing Table is a Stripe-hosted, embeddable <stripe-pricing-table> web component. You define your products, prices, and branding in the Stripe Dashboard — no code required for the UI itself. Drop the component into any Blade view and Stripe renders the card grid, billing period toggle, promo code field, and checkout flow. It runs inside an iframe and is PCI-compliant by default.
A few constraints worth knowing before you start:
- Maximum four products with up to three prices each
- Flat-rate and per-seat pricing only — no usage-based (metered) prices
- No support for account creation during checkout
If you're already familiar with creating Stripe Checkout sessions in Laravel without Cashier, the Pricing Table uses the same Checkout infrastructure under the hood — the webhook patterns are identical. The difference is you're skipping the custom pricing page UI entirely.
Creating Your Pricing Table in the Dashboard
No code needed at this step. In the Stripe Dashboard, go to Products → Pricing tables and click Create pricing table. Select your products, configure the billing period toggle (monthly/annual), set your branding, and define success and cancel redirect URLs.
When you save, Stripe gives you two values:
- A
pricing-table-idstarting withprctbl_ - Your publishable key (you already have this from your Stripe setup)
Add both to your .env — you'll want separate IDs for test and production environments:
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_PRICING_TABLE_ID=prctbl_live_...
# Local/test equivalents
# STRIPE_PUBLISHABLE_KEY=pk_test_...
# STRIPE_PRICING_TABLE_ID=prctbl_test_...
Wire these into config/services.php:
// config/services.php
'stripe' => [
'key' => env('STRIPE_PUBLISHABLE_KEY'),
'secret' => env('STRIPE_SECRET_KEY'),
'pricing_table_id' => env('STRIPE_PRICING_TABLE_ID'),
],
Embedding the stripe pricing table in a Blade View
For a public pricing page with no user tracking, the embed is minimal — just the Stripe.js script tag and the custom element:
{{-- Load Stripe.js once — best placed in your main layout <head> --}}
<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
{{-- The pricing table web component --}}
<stripe-pricing-table
pricing-table-id="{{ config('services.stripe.pricing_table_id') }}"
publishable-key="{{ config('services.stripe.key') }}"
>
</stripe-pricing-table>
Stripe renders the entire UI inside an iframe. No custom CSS or plan card components required. If your Content Security Policy (CSP) blocks iframes, add these directives:
frame-src https://js.stripe.com;
script-src https://js.stripe.com;
Linking Existing Users with Customer Sessions
The guest embed has a significant problem: when an authenticated user subscribes, Stripe creates a brand-new customer and only passes a client-reference-id in the webhook payload. Mapping that back to your User record requires extra handling and is fragile.
The correct approach is a customer session: a short-lived, server-generated secret you pass to the component that tells Stripe "this embed belongs to my existing customer." With customer-session-client-secret set, Stripe uses your existing Stripe customer for the checkout — no new customer is created, no manual mapping needed.
Create the customer session in a controller:
// app/Http/Controllers/PricingController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Laravel\Cashier\Cashier;
class PricingController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
// Ensure the user has a Stripe customer record before generating a session
$user->createOrGetStripeCustomer();
// Customer sessions expire after 30 minutes — always generate fresh on each page load
$customerSession = Cashier::stripe()->customerSessions->create([
'customer' => $user->stripeId(),
'components' => [
'pricing_table' => ['enabled' => true],
],
]);
return view('pricing', [
'clientSecret' => $customerSession->client_secret,
]);
}
}
Pass the secret to the component in your Blade view:
{{-- resources/views/pricing.blade.php --}}
<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
<stripe-pricing-table
pricing-table-id="{{ config('services.stripe.pricing_table_id') }}"
publishable-key="{{ config('services.stripe.key') }}"
customer-session-client-secret="{{ $clientSecret }}"
>
</stripe-pricing-table>
Register the route behind auth middleware:
// routes/web.php
Route::get('/pricing', [PricingController::class, 'index'])
->middleware(['auth', 'verified'])
->name('pricing');
Keep your public marketing pricing page as a separate guest route without the customer session.
Handling the Webhook After Checkout
When a user completes checkout, Stripe fires checkout.session.completed. If you used a customer session, the payload's customer field is your existing Stripe customer ID — look up the user directly by stripe_id.
Exclude the webhook route from CSRF protection in bootstrap/app.php:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
]);
})
Cashier handles checkout.session.completed automatically and syncs the subscription to your subscriptions table. For custom provisioning — updating your own tables or dispatching jobs — listen to the WebhookReceived event:
// app/Providers/AppServiceProvider.php
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\HandleStripeWebhook;
public function boot(): void
{
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
}
// app/Listeners/HandleStripeWebhook.php
namespace App\Listeners;
use App\Models\User;
use Laravel\Cashier\Events\WebhookReceived;
class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] !== 'checkout.session.completed') {
return;
}
$session = $event->payload['data']['object'];
$stripeId = $session['customer'] ?? null;
if (! $stripeId) {
return;
}
$user = User::where('stripe_id', $stripeId)->first();
// Idempotency guard — Stripe can redeliver the same event on failure
if ($user?->hasActiveSubscription()) {
return;
}
$user?->grantAccess();
}
}
For implementing a webhook controller outside of Cashier, verifying Stripe webhook signatures in Laravel covers the Webhook::constructEvent() pattern and the raw body requirement in detail. Once checkout.session.completed is handled, the events that follow — renewals, payment failures, cancellations — are covered in handling Stripe subscription lifecycle events in Laravel.
Comparing with Cashier Checkout
| Pricing Table | Cashier Checkout | |
|---|---|---|
| Plan card UI | Stripe-rendered | Build in Blade |
| Billing toggle | Built-in | Build yourself |
| Promo codes | Built-in | Configurable |
| Customer tracking | Customer sessions | Automatic via Billable |
| Customisation | Dashboard settings | Full control |
| Best for | Marketing pricing pages | App-embedded flows |
The Pricing Table is the right call when you want a maintainable pricing page without custom UI work. Use Cashier's newSubscription()->checkout() directly when you need multi-step flows, custom plan selection logic, or anything the Pricing Table dashboard doesn't support.
Gotchas and Edge Cases
Customer sessions expire in 30 minutes. Generate the secret on each page load — never cache it. If a user opens the pricing page, leaves, and returns after 30 minutes, they need a fresh load. The component will fail silently or show an error if the secret has expired.
Without a customer session, Stripe creates a new customer. Skip customer-session-client-secret for an authenticated user and Stripe creates a fresh Stripe customer at checkout. Your webhook only receives a client-reference-id — not your user's stripe_id — to link the purchase back. This is solvable with careful webhook handling but significantly more fragile.
Test and live pricing table IDs are not interchangeable. A prctbl_test_* ID only works with pk_test_* keys. Mix them and the component won't render — with no obvious error in the browser.
The client_secret must not be logged or cached server-side. Treat it like a short-lived auth token: generate it, pass it to the view, let it expire.
You can't override pricing per-user through the component. The Pricing Table always renders what's in the dashboard. For per-user discounts, apply a Stripe coupon to the customer's account before they reach the pricing page — Cashier's $user->applyCoupon('COUPON_ID') handles this.
Locale is browser-controlled. The component renders in the browser's reported locale automatically. There's no attribute to force a specific language.
Wrapping Up
The Stripe Pricing Table handles the full pricing page UI — plan cards, billing toggle, promo codes, checkout — with a single web component. Embed it in Blade, generate a customer session for authenticated users, and handle checkout.session.completed to provision access. That's the complete stripe pricing table laravel integration. Once users are subscribed, adding a Stripe Customer Portal gives them self-serve plan changes and cancellations in a single redirect. If you want a trial-first flow before users hit the pricing table, implementing Stripe trials for memberships covers the patterns that pair well with this setup.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.