Registration & Onboarding
SaaS4Builders provides two ways to create a user account: direct registration (user + tenant, no billing) and onboarding (user + tenant + plan selection + Stripe checkout). Most production deployments use the onboarding flow, which combines signup with plan selection in a single experience.
Two Registration Paths
| Direct Registration | Onboarding | |
|---|---|---|
| Endpoint | POST /api/v1/auth/register | POST /api/v1/onboarding/start |
| Creates subscription? | No | Yes (free = instant, paid = Stripe Checkout) |
| When to use | Billing is decoupled from signup | Signup includes plan selection |
| Controller | AuthController::register | OnboardingController::start |
| Action | RegisterUser | StartOnboarding (wraps RegisterUser) |
Direct Registration
The simplest path. Creates a user and their first tenant, then returns a token pair.
Request
POST /api/v1/auth/register
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | User's full name |
email | string | Yes | Must be unique |
password | string | Yes | Min 8 chars, must match password_confirmation |
password_confirmation | string | Yes | Must match password |
tenant_name | string | No | Defaults to "{name}'s Organization" |
tenant_slug | string | No | Auto-generated if omitted |
What Happens
The RegisterUser action creates the user and tenant in a single transaction:
final class RegisterUser
{
public function __construct(
private readonly CreateTenant $createTenant,
) {}
public function execute(RegisterUserData $data): array
{
return DB::transaction(function () use ($data): array {
$user = User::create([
'name' => $data->name,
'email' => $data->email,
'password' => $data->password,
]);
$tenantName = $data->tenantName ?? $user->name."'s Organization";
$tenant = $this->createTenant->execute(
new CreateTenantData(
name: $tenantName,
slug: $data->tenantSlug,
),
$user,
);
return [
'user' => $user->load('tenants'),
'tenant' => $tenant,
];
});
}
}
The user is assigned the owner role on the new tenant. A token pair (access + refresh) is generated after the transaction completes.
Response (201)
{
"user": {
"id": 42,
"name": "Jane Smith",
"email": "jane@example.com",
"is_platform_admin": false,
"tenants": [
{
"id": "9f8a7b6c-...",
"name": "Jane Smith's Organization",
"slug": "jane-smiths-organization"
}
]
},
"tenant": {
"id": "9f8a7b6c-...",
"name": "Jane Smith's Organization"
},
"accessToken": "1|abc123...",
"refreshToken": "def456..."
}
The Onboarding Flow
The onboarding flow combines user registration with plan selection and Stripe checkout. It handles both free and paid plans.
The Start Request
POST /api/v1/onboarding/start
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | User's full name |
email | string | Yes | Must be unique |
password | string | Yes | Min 8 chars, confirmed |
password_confirmation | string | Yes | Must match password |
plan_slug | string | Yes | Slug of the selected plan (must exist) |
currency | string | Yes | 3-letter currency code (must have a price for this plan) |
tenant_name | string | No | Defaults to "{name}'s Organization" |
quantity | integer | No | Seat count for seat-based plans (default: 1) |
Response — Paid Plan (201)
{
"user": { "id": 42, "name": "Jane Smith", "email": "jane@example.com" },
"tenant": { "id": "9f8a7b6c-...", "name": "Jane Smith's Organization" },
"checkout_url": "https://checkout.stripe.com/c/pay/cs_...",
"accessToken": "1|abc123...",
"refreshToken": "def456..."
}
The frontend redirects the user to checkout_url for payment.
Response — Free Plan (201)
{
"user": { "id": 42, "name": "Jane Smith", "email": "jane@example.com" },
"tenant": { "id": "9f8a7b6c-...", "name": "Jane Smith's Organization" },
"accessToken": "1|abc123...",
"refreshToken": "def456..."
}
No checkout_url — the subscription is created immediately and onboarding is marked complete. The frontend navigates directly to the dashboard.
StartOnboarding Action
The StartOnboarding action orchestrates the entire flow:
final class StartOnboarding
{
public function __construct(
private readonly RegisterUser $registerUser,
private readonly PaymentGatewayInterface $gateway,
) {}
public function execute(StartOnboardingData $data): OnboardingResult
{
$plan = Plan::where('slug', $data->planSlug)->firstOrFail();
$planPrice = $plan->getPriceForCurrency($data->currency);
if ($planPrice === null) {
throw new PlanNotAvailableInCurrencyException(
$data->planSlug, $data->currency,
$plan->getAvailableCurrencies(),
);
}
// Register user + tenant in a single transaction
['user' => $user, 'tenant' => $tenant] = DB::transaction(function () use ($data): array {
$result = $this->registerUser->execute(new RegisterUserData(
name: $data->name,
email: $data->email,
password: $data->password,
tenantName: $data->tenantName,
));
$result['tenant']->update([
'preferred_currency' => $data->currency,
'billing_email' => $data->email,
]);
return $result;
});
// External calls — intentionally outside the DB transaction
$this->gateway->ensureCustomer($tenant);
if ($planPrice->price_cents === 0) {
return $this->handleFreePlan($data, $plan, $planPrice, $user, $tenant);
}
return $this->handlePaidPlan($data, $plan, $planPrice, $user, $tenant);
}
}
Free Plan Handling
For plans with price_cents === 0, the action creates a Stripe subscription server-side (no Checkout redirect), creates the local Subscription record, and marks onboarding as complete:
private function handleFreePlan(...): OnboardingResult
{
$stripeSubscription = $this->gateway->createSubscription(
$customerId,
$planPrice->stripe_price_id,
[
'quantity' => $data->quantity,
'metadata' => ['tenant_id' => $tenant->id, 'plan_id' => $plan->id],
],
);
Subscription::create([
'tenant_id' => $tenant->id,
'plan_id' => $plan->id,
'stripe_subscription_id' => $stripeSubscription['id'],
'status' => StripeStatusMapper::map($stripeSubscription['status']),
'currency' => $data->currency,
'price_cents' => 0,
// ... period dates, quantity
]);
$tenant->markOnboardingComplete();
return new OnboardingResult(user: $user, tenant: $tenant, checkoutUrl: null);
}
Paid Plan Handling
For paid plans, the action creates a Stripe Checkout session with preconfigured success and cancel URLs:
private function handlePaidPlan(...): OnboardingResult
{
$frontendUrl = rtrim(config('app.frontend_url'), '/');
$successUrl = $frontendUrl . config('billing.onboarding.checkout_success_path');
$cancelUrl = $frontendUrl . config('billing.onboarding.checkout_cancel_path');
$checkoutSession = $this->gateway->createCheckoutSession(new CreateCheckoutData(
tenant: $tenant,
planPrice: $planPrice,
successUrl: $successUrl,
cancelUrl: $cancelUrl,
quantity: $data->quantity,
trialDays: $plan->trial_days > 0 ? $plan->trial_days : null,
));
return new OnboardingResult(
user: $user, tenant: $tenant, checkoutUrl: $checkoutSession->url,
);
}
Cookie vs Token Mode
The controller handles both auth modes when returning the response:
$isStateful = EnsureFrontendRequestsAreStateful::fromFrontend($request);
if ($isStateful) {
// SPA (cookie mode): establish session so the cookie survives the Stripe redirect
Auth::guard('web')->login($result->user);
$request->session()->regenerate();
} else {
// API (token mode): generate token pair for stateless clients
$tokens = $refreshAction->createTokenPair($result->user);
$response['accessToken'] = $tokens['accessToken'];
$response['refreshToken'] = $tokens['refreshToken'];
}
Status Polling and Completion
After a successful Stripe Checkout, the user is redirected back to your application. The subscription is created asynchronously via Stripe webhooks, so the frontend polls for confirmation.
Checking Onboarding Status
GET /api/v1/onboarding/status
Auth: auth:sanctum + tenant.resolve
Returns the current state of the tenant's onboarding:
{
"data": {
"tenant_id": "9f8a7b6c-...",
"onboarding_completed": false,
"has_subscription": true,
"subscription_status": "active",
"plan_name": "Pro",
"has_billing_details": false,
"billing_details": {
"legal_name": null,
"address": null,
"city": null,
"postal_code": null,
"country": null,
"vat_number": null,
"billing_email": "jane@example.com"
}
}
}
Frontend Polling
The OnboardingStatusCheck component polls the status endpoint every 2 seconds for a maximum of 30 seconds (15 attempts):
<script setup lang="ts">
const MAX_POLLS = 15
const POLL_INTERVAL_MS = 2_000
let pollCount = 0
const intervalId = setInterval(async () => {
pollCount++
const status = await onboardingApi.getOnboardingStatus()
if (status.hasSubscription) {
clearInterval(intervalId)
emit('subscription-confirmed', status)
return
}
if (pollCount >= MAX_POLLS) {
clearInterval(intervalId)
showTimeoutError.value = true
}
}, POLL_INTERVAL_MS)
</script>
If the subscription is not confirmed within 30 seconds, the component shows a timeout message with a retry button. This can happen if the Stripe webhook is delayed.
Completing Onboarding
Once the subscription is confirmed, the user can optionally provide billing details:
PATCH /api/v1/onboarding/complete-setup
Auth: auth:sanctum + tenant.resolve
| Field | Type | Required | Description |
|---|---|---|---|
legal_name | string | No | Business legal name |
address | string | No | Street address |
city | string | No | City |
postal_code | string | No | Postal/ZIP code |
country | string | No | 2-letter ISO country code |
vat_number | string | No | VAT registration number |
billing_email | string | No | Billing contact email |
All fields are optional. The action verifies that an active or trialing subscription exists, updates any provided billing fields, and marks onboarding as complete:
final class CompleteOnboarding
{
public function execute(CompleteOnboardingData $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$subscription = Subscription::query()
->where('tenant_id', $data->tenant->id)
->whereIn('status', [SubscriptionStatus::Active, SubscriptionStatus::Trialing])
->latest('created_at')
->first();
if ($subscription === null) {
throw new OnboardingPaymentRequiredException;
}
$billingFields = array_filter([
'legal_name' => $data->legalName,
'address' => $data->address,
// ... city, postal_code, country, vat_number, billing_email
], fn (mixed $value): bool => $value !== null);
if ($billingFields !== []) {
$data->tenant->update($billingFields);
}
$data->tenant->markOnboardingComplete();
return $data->tenant->refresh();
});
}
}
Retry Checkout
If a user abandoned Stripe Checkout or payment failed, they can retry:
POST /api/v1/onboarding/retry-checkout
Auth: auth:sanctum + tenant.resolve
| Field | Type | Required | Description |
|---|---|---|---|
plan_slug | string | Yes | Plan to subscribe to |
currency | string | Yes | 3-letter currency code |
quantity | integer | No | Seat count (default: 1) |
Response (200)
{
"data": {
"checkout_url": "https://checkout.stripe.com/c/pay/cs_..."
}
}
The frontend redirects the user to the new checkout_url. If the tenant already has an active subscription, the endpoint returns an AlreadySubscribedException error.
Frontend: useOnboarding Composable
The useOnboarding composable wraps all onboarding API calls with loading and error state:
const { status, statusPending, isLoading, error, start, retryCheckout, completeSetup, refreshStatus }
= useOnboarding()
// Start onboarding (signup page)
const result = await start({
name: 'Jane Smith',
email: 'jane@example.com',
password: 'securepassword',
passwordConfirmation: 'securepassword',
planSlug: 'pro',
currency: 'usd',
})
if (result.checkoutUrl) {
window.location.href = result.checkoutUrl // Redirect to Stripe
}
// After Stripe redirect — check status
await refreshStatus()
// Complete onboarding with billing info
await completeSetup({
legalName: 'Acme Inc.',
country: 'US',
billingEmail: 'billing@acme.com',
})
Stale Tenant Cleanup
Tenants that start onboarding but never complete it (abandoned signups, failed payments) accumulate over time. A scheduled command cleans them up:
# Preview what would be deleted
docker compose exec php php artisan onboarding:cleanup-stale --dry-run
# Delete stale tenants older than 14 days (default)
docker compose exec php php artisan onboarding:cleanup-stale
# Custom threshold
docker compose exec php php artisan onboarding:cleanup-stale --days=7
The command soft-deletes tenants where onboarding_completed_at is null and created_at is older than the threshold. If the tenant's owner has no other tenants, the orphaned user is hard-deleted along with their tokens.
$query = Tenant::query()
->whereNull('onboarding_completed_at')
->where('created_at', '<', $cutoff);
$query->chunkById(100, function ($tenants) use (&$tenantsDeleted, &$usersDeleted): void {
foreach ($tenants as $tenant) {
DB::transaction(function () use ($tenant, &$tenantsDeleted, &$usersDeleted): void {
$owner = $tenant->owner;
$tenant->delete();
$tenantsDeleted++;
if ($owner !== null && $owner->tenants()->count() === 0) {
$owner->tokens()->delete();
$owner->refreshTokens()->delete();
$owner->delete();
$usersDeleted++;
}
});
}
});
Error Handling
| Error | HTTP | Code | When |
|---|---|---|---|
| Plan not found | 404 | — | plan_slug doesn't match any plan |
| Plan not available in currency | 422 | PLAN_NOT_AVAILABLE_IN_CURRENCY | The plan has no price for the requested currency |
| Already subscribed | 409 | ALREADY_SUBSCRIBED | Tenant already has an active subscription to this plan |
| Payment required | 403 | ONBOARDING_PAYMENT_REQUIRED | Trying to complete onboarding without an active subscription |
| Tenant context required | 403 | TENANT_CONTEXT_REQUIRED | Authenticated but no tenant resolved |
What's Next
- Login, Logout & Token Refresh — How returning users authenticate
- Auth Architecture Overview — The full middleware and permission system
- Stripe Integration — How checkout and webhooks work under the hood