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.idcolumn
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 frontend — frontend/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
CreateDatabasepipeline step - Tenant migrations run (
database/migrations/tenant/+ all modules' tenant migrations) DatabaseMigratedevent fires → module auto-install runs (seeconfig/reseller-admin.phponboarding.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.