Tenant Onboarding Flows

There are three code paths that create a new tenant. Only one of them is active today for public traffic; the other two exist for specific admin and install-time flows.

1. Reseller onboarding (the active public flow)

Entry point: POST /api/v1/reseller-register/apply with a valid referral code.

┌─────────────────────────────────────────────────────────────────┐
│  Applicant                                                       │
│    submits name, email, phone, business details, password        │
│    (optionally) preferred_domain                                 │
├─────────────────────────────────────────────────────────────────┤
│  PublicRegistrationController::apply                             │
│    validates referral code                                       │
│    validates preferred_domain format (if given)                  │
│    rejects reserved words (www, api, admin, …)                   │
├─────────────────────────────────────────────────────────────────┤
│  ResellerOnboardingService::submitApplication                    │
│    writes ResellerApplication row (status=pending)               │
│    if KYC required → stops here, returns application_id          │
├─────────────────────────────────────────────────────────────────┤
│  [if KYC required] Applicant uploads documents via               │
│    POST /api/v1/reseller-register/{id}/documents                 │
│    Admin reviews & marks kyc_status=verified                     │
├─────────────────────────────────────────────────────────────────┤
│  Admin approval (OR auto-approve if KYC not required)            │
│    ResellerOnboardingService::approveApplication                 │
│    dispatches ProvisionResellerTenantJob (Horizon queue)         │
├─────────────────────────────────────────────────────────────────┤
│  ProvisionResellerTenantJob (async, builds queue, 3 retries)    │
│    1. Generate random 8-char tenant ID (unique, always)          │
│    2. Create Tenant row + run migration pipeline (Stancl)        │
│    3. Create primary Domain row for the random ID                │
│    4. Try preferred_domain for the human alias                   │
│       if unavailable → fall back to slug(business_name)          │
│    5. Create user + link to tenant as owner                      │
│    6. Trigger DatabaseMigrated event → modules auto-install      │
│    7. Mark application completed                                 │
└─────────────────────────────────────────────────────────────────┘

Output: tenant reachable at <random_id>.autocom.app and <human_alias>.autocom.app.

2. Direct public signup (AuthController::register)

Entry point: POST /api/v1/auth/tenants.

The caller picks their own subdomain (domain field, required). Validation:

  • alpha_dash (letters, numbers, underscore, hyphen)
  • max 63 characters (DNS label limit)
  • unique across the tenants.id column

If the validator passes, the domain is slugified and becomes both the tenant ID and the primary domain record:

$tenantId = Str::slug($validated['domain']);
Tenant::create(['id' => $tenantId, /* ... */]);
$tenant->domains()->create(['domain' => $validated['domain']]);

This path is currently disabled from the frontendfrontend/app/signup/page.tsx redirects to /login. The backend route exists and works; it's just not wired to a public UI. Leaving it available lets you add a self-serve signup later without backend work.

3. Install wizard (InstallController::createOwner)

Entry point: POST /api/v1/install/owner, called exactly once during fresh platform installation.

The installer chooses organization_slug, which becomes the landlord tenant's ID. Validation is identical to path 2 but with max 50 chars:

'organization_slug' => 'required|string|max:50|alpha_dash|unique:tenants,id',

The first tenant created this way gets the platform_owner role — they're the landlord, not a regular tenant. All subsequent tenants come from path 1 (reseller) or, if you re-enable the UI, path 2 (public signup).

Feature matrix

Path 1 (reseller) Path 2 (signup) Path 3 (install)
Who can call it Anyone with a valid referral code Anyone (UI disabled) Only once, during install
Subdomain source Auto-gen random + optional preferred_domain alias User picks Installer picks
KYC required Yes (configurable) No No
Admin approval Yes (unless KYC off + auto-approve on) No No
Async provisioning Yes (Horizon builds queue) Sync in request Sync in request
Tenant role From referral code (retailer/distributor/etc) Owner platform_owner

What the applicant sees

For the reseller flow, the response body depends on the current state:

// Submit succeeded, KYC required
{
  "message": "Application submitted. Please upload your KYC documents.",
  "application_id": "uuid",
  "status": "pending",
  "kyc_required": true,
  "required_documents": ["government_id", "selfie"]
}

// Submit succeeded, auto-approved (KYC off + auto_approve on)
{
  "message": "Registration complete. You can now log in.",
  "application_id": "uuid",
  "status": "completed",
  "tenant_id": "a3x8k2pq"
}

// Duplicate email found
{
  "message": "An application already exists for this email.",
  "application_id": "existing-uuid",
  "status": "pending",
  "kyc_status": "pending",
  "kyc_required": true,
  "required_documents": ["government_id", "selfie"]
}

The applicant can poll GET /api/v1/reseller-register/{id}/status to follow their application through the KYC → approval → provisioning → completion states.

Provisioning is async

Tenant DB creation + migration + module installation takes 10-30 seconds of real work. To keep the approval endpoint responsive, the approval just dispatches a Horizon job and returns status: "provisioning" immediately. The job runs on the builds queue with 3 retries and 10/30/60-second backoffs.

Side effects during provisioning:

  • Tenant database is created via Stancl's CreateDatabase pipeline step
  • Tenant migrations run (database/migrations/tenant/ + all modules' tenant migrations)
  • DatabaseMigrated event fires → module auto-install runs (see config/reseller-admin.php onboarding.auto_install_modules)
  • Mandatory products are synced from the parent reseller's catalog
  • Owner user is created (or existing user is linked) and joined to the tenant

If provisioning fails, the application's rejection_reason field is populated with the error for admin visibility. After 3 retries, the job is marked permanently failed.