Skip to content
SaaS4Builders
Authentication

Registration & Onboarding

Two registration paths, the onboarding checkout flow with Stripe, status polling, and stale tenant cleanup.

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 RegistrationOnboarding
EndpointPOST /api/v1/auth/registerPOST /api/v1/onboarding/start
Creates subscription?NoYes (free = instant, paid = Stripe Checkout)
When to useBilling is decoupled from signupSignup includes plan selection
ControllerAuthController::registerOnboardingController::start
ActionRegisterUserStartOnboarding (wraps RegisterUser)
Both paths create a user and a tenant in a single database transaction. The onboarding flow adds Stripe customer creation and subscription setup on top.

Direct Registration

The simplest path. Creates a user and their first tenant, then returns a token pair.

Request

POST /api/v1/auth/register
FieldTypeRequiredDescription
namestringYesUser's full name
emailstringYesMust be unique
passwordstringYesMin 8 chars, must match password_confirmation
password_confirmationstringYesMust match password
tenant_namestringNoDefaults to "{name}'s Organization"
tenant_slugstringNoAuto-generated if omitted

What Happens

The RegisterUser action creates the user and tenant in a single transaction:

backend/app/Application/Auth/Actions/RegisterUser.php
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.

flowchart TD
    A["Signup Form<br/>(plan + user details)"] -->|"POST /onboarding/start"| B["StartOnboarding<br/>RegisterUser<br/>ensureCustomer()<br/>free? / paid?"]
    B --> C["Free plan ($0)<br/>createSubscription()<br/>markOnboardingComplete()<br/>return (no checkout_url)"]
    B --> D["Paid plan<br/>createCheckoutSession()<br/>return checkout_url"]
    C --> E["Dashboard"]
    D --> F["Stripe Checkout"]
    F -->|success| G["/onboarding/welcome"]
    G --> H["Poll GET /onboarding/status<br/>(every 2s, max 30s)"]
    H --> I["subscription confirmed"]
    I --> J["PATCH /onboarding/complete-setup<br/>(optional billing fields)"]
    J --> K["Dashboard"]

The Start Request

POST /api/v1/onboarding/start
FieldTypeRequiredDescription
namestringYesUser's full name
emailstringYesMust be unique
passwordstringYesMin 8 chars, confirmed
password_confirmationstringYesMust match password
plan_slugstringYesSlug of the selected plan (must exist)
currencystringYes3-letter currency code (must have a price for this plan)
tenant_namestringNoDefaults to "{name}'s Organization"
quantityintegerNoSeat 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:

backend/app/Application/Onboarding/Actions/StartOnboarding.php
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);
    }
}
External API calls (Stripe customer creation, checkout session) happen outside the database transaction. If Stripe fails, the user and tenant still exist in the database. This is intentional — the retry-checkout endpoint handles payment recovery.

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:

backend/app/Application/Onboarding/Actions/StartOnboarding.php
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);
}

For paid plans, the action creates a Stripe Checkout session with preconfigured success and cancel URLs:

backend/app/Application/Onboarding/Actions/StartOnboarding.php
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,
    );
}

The controller handles both auth modes when returning the response:

backend/app/Http/Controllers/Api/V1/Onboarding/OnboardingController.php
$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):

frontend/features/product/onboarding/components/OnboardingStatusCheck.vue
<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

FieldTypeRequiredDescription
legal_namestringNoBusiness legal name
addressstringNoStreet address
citystringNoCity
postal_codestringNoPostal/ZIP code
countrystringNo2-letter ISO country code
vat_numberstringNoVAT registration number
billing_emailstringNoBilling 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:

backend/app/Application/Onboarding/Actions/CompleteOnboarding.php
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

FieldTypeRequiredDescription
plan_slugstringYesPlan to subscribe to
currencystringYes3-letter currency code
quantityintegerNoSeat 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:

frontend/features/product/onboarding/composables/useOnboarding.ts
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.

backend/app/Console/Commands/CleanupStaleTenants.php
$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++;
            }
        });
    }
});
Schedule this command in production. Add it to your Laravel scheduler (e.g., daily at 4:00 AM) to prevent stale tenant accumulation.

Error Handling

ErrorHTTPCodeWhen
Plan not found404plan_slug doesn't match any plan
Plan not available in currency422PLAN_NOT_AVAILABLE_IN_CURRENCYThe plan has no price for the requested currency
Already subscribed409ALREADY_SUBSCRIBEDTenant already has an active subscription to this plan
Payment required403ONBOARDING_PAYMENT_REQUIREDTrying to complete onboarding without an active subscription
Tenant context required403TENANT_CONTEXT_REQUIREDAuthenticated but no tenant resolved

What's Next