Stripe Connect Express Onboarding in Laravel
If you're building a marketplace in Laravel, you need sellers to accept payments through your platform. Stripe Connect Express is the right tool: Stripe hosts the onboarding flow, handles KYC verification, and manages payouts to your sellers. You wire up a few API calls and take a percentage of every transaction.
This is a different beast from basic Stripe Checkout — if you just need one-time payments for your own account, Stripe Checkout Sessions in Laravel covers that. For platforms with multiple sellers, read on.
Creating Connected Accounts with Stripe Connect in Laravel
Install the Stripe PHP SDK if you haven't already:
composer require stripe/stripe-php
Add your keys to config/services.php:
'stripe' => [
'secret' => env('STRIPE_SECRET'),
'key' => env('STRIPE_KEY'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'connect_webhook_secret' => env('STRIPE_CONNECT_WEBHOOK_SECRET'),
],
Note the separate connect_webhook_secret — Connect webhooks use a different secret from your platform webhooks.
Add a stripe_connect_id column to your users table (or a dedicated sellers table):
php artisan make:migration add_stripe_connect_id_to_users_table
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('stripe_connect_id')->nullable()->after('id');
$table->boolean('stripe_connect_onboarded')->default(false)->after('stripe_connect_id');
});
}
When a seller registers on your platform, create their Express account:
// app/Http/Controllers/ConnectController.php
use Stripe\StripeClient;
class ConnectController extends Controller
{
public function __construct(private readonly StripeClient $stripe)
{
}
public function create(Request $request): RedirectResponse
{
$user = $request->user();
if ($user->stripe_connect_id) {
return redirect()->route('connect.onboarding');
}
$account = $this->stripe->accounts->create([
'type' => 'express',
'country' => 'GB',
'email' => $user->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
]);
$user->update(['stripe_connect_id' => $account->id]);
return redirect()->route('connect.onboarding');
}
}
Bind the StripeClient in a service provider so you don't instantiate it in the constructor manually:
// app/Providers/AppServiceProvider.php
$this->app->singleton(StripeClient::class, fn () => new StripeClient(config('services.stripe.secret')));
The Hosted Stripe Connect Onboarding Flow
Once the Express account exists, generate an Account Link to send the seller to Stripe's hosted onboarding UI. This link is single-use and expires after a few minutes — always generate it fresh:
public function onboarding(Request $request): RedirectResponse
{
$user = $request->user();
$accountLink = $this->stripe->accountLinks->create([
'account' => $user->stripe_connect_id,
'refresh_url' => route('connect.onboarding'), // regenerates link on expiry
'return_url' => route('connect.return'),
'type' => 'account_onboarding',
]);
return redirect($accountLink->url);
}
public function return(Request $request): RedirectResponse
{
// The seller landed here after the onboarding flow
// Do NOT assume onboarding is complete — use the webhook to confirm
return redirect()->route('dashboard')->with('status', 'Onboarding submitted — we\'ll notify you when your account is approved.');
}
Register the routes:
// routes/web.php
Route::middleware('auth')->group(function (): void {
Route::post('/connect/create', [ConnectController::class, 'create'])->name('connect.create');
Route::get('/connect/onboarding', [ConnectController::class, 'onboarding'])->name('connect.onboarding');
Route::get('/connect/return', [ConnectController::class, 'return'])->name('connect.return');
});
Webhook Handling for Account Onboarding Status
The return URL fires when the seller leaves Stripe's UI — whether they finished or gave up halfway. The only reliable way to know an account is fully onboarded is the account.updated webhook.
Register a separate Connect webhook endpoint in your Stripe dashboard under Connect → Webhooks, listening for account.updated. This endpoint uses your connect_webhook_secret, not your platform webhook secret. Before building handlers, make sure you understand how Stripe webhook signature verification works in Laravel — the same pattern applies here with a different secret.
// app/Http/Controllers/ConnectWebhookController.php
class ConnectWebhookController extends Controller
{
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
config('services.stripe.connect_webhook_secret')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
if ($event->type === 'account.updated') {
$this->handleAccountUpdated($event->data->object);
}
return response('OK', 200);
}
private function handleAccountUpdated(\Stripe\Account $account): void
{
$user = User::where('stripe_connect_id', $account->id)->first();
if (! $user) {
return;
}
// Only mark as onboarded when both conditions are true
if ($account->charges_enabled && $account->details_submitted) {
$user->update(['stripe_connect_onboarded' => true]);
// Notify the seller their account is live
}
}
}
Add the route and exclude it from CSRF protection:
// routes/web.php
Route::post('/stripe/connect/webhook', [ConnectWebhookController::class, 'handle'])
->name('stripe.connect.webhook');
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
'stripe/connect/webhook',
]);
})
The account.updated event fires on any change to the account — not just onboarding. The dual check on charges_enabled and details_submitted ensures you only flag the account as ready when Stripe has everything it needs. This is the same pattern used throughout Stripe subscription lifecycle event handling in Laravel — always gate on the status field, not just the event type.
Routing Payments to Connected Accounts
With the seller onboarded, use transfer_data on a PaymentIntent to route funds to their account. The application_fee_amount is your platform's cut — it stays in your Stripe balance:
// app/Services/PaymentService.php
public function chargeForOrder(Order $order): \Stripe\PaymentIntent
{
$seller = $order->seller;
$platformFeePercent = 0.10; // 10% platform fee
$platformFee = (int) round($order->amount * $platformFeePercent);
return $this->stripe->paymentIntents->create([
'amount' => $order->amount, // in smallest currency unit (pence/cents)
'currency' => 'gbp',
'payment_method_types' => ['card'],
'application_fee_amount' => $platformFee,
'transfer_data' => [
'destination' => $seller->stripe_connect_id,
],
'metadata' => [
'order_id' => $order->id,
],
]);
}
Both amount and application_fee_amount are in the smallest currency unit. A £49.99 order is 4999. A 10% fee is 499. The remaining 4500 transfers to the seller after Stripe's fees.
Gotchas and Edge Cases
Refresh URL must regenerate the Account Link. When refresh_url fires, the original onboarding link is expired. Point refresh_url at the same onboarding action that creates a new Account Link — don't redirect to a static page.
Two separate webhook endpoints. Your platform webhooks (subscriptions, payment intents) and Connect webhooks (account updates) have different secrets in Stripe's dashboard. Mixing them up is the most common source of signature verification failures.
account.updated fires constantly. Every time Stripe verifies a document or updates a policy, this event fires. Your handler runs multiple times — make sure the update to stripe_connect_onboarded is idempotent (it is if you're just setting a boolean).
Test mode onboarding. In test mode, Stripe skips real KYC. Use the test account number 000123456789, sort code 108800, and any valid date of birth. Your account.updated webhook will fire with charges_enabled: true immediately after completing the hosted flow.
Check livemode on the event. If you're processing test and live webhooks against the same endpoint during development, verify $event->livemode matches your expected environment before acting on it.
Wrapping Up
The core loop is: create an Express account, redirect to the Account Link, wait for account.updated to confirm onboarding, then use transfer_data on PaymentIntents to route funds. Once your sellers are onboarded, you can extend the platform with a Stripe Customer Portal for self-serve billing management or move toward usage-based billing with Stripe Meters if your pricing model evolves.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.