Setup Module
Overview
The Setup module is the first-run wizard that configures a fresh Sutomo installation. It acts as a gate: until all required data is present, every request is redirected to /setup. Once complete, the application behaves normally.
The module spans both backend (PHP middleware + controllers) and frontend (Svelte wizard). It's the only module that can lock the entire application.
Concept
Initial State
A fresh installation has zero data. The system uses three levels of checks in IdentifierAppService to determine what state the application is in:
| Check | What it answers | Used by |
|---|---|---|
setup() | Does a super user exist? | SetupMiddleware |
seeder() | Is the database seeded? | MaintenanceMiddleware |
initial() | Is public content ready? | Maintenance + frontend checks |
These checks cascade from most fundamental (super user) to most specific (FAQ content).
Setup Completion Tiers
isSetup() uses 5 tiers. Each tier must pass before the next is checked:
| Tier | What's required | Failure impact |
|---|---|---|
| 1 — Auth | At least 1 User (admin) | No authentication possible |
| 2 — Seeds | Geographic, gender, ethnicity, localization, religion | Forms missing required dropdown data |
| 3 — Content | 1 School + 1 Article | Public pages have nothing to show |
| 4 — Branding | Core settings + yayasan_name | Header/footer/SEO incomplete |
| 5 — Contact | 1 contact method + 1 social media | Public has no way to reach the school |
State Machine
The wizard progresses through 5 states. Each transition is one-way — there is no "undo setup" in the application.
┌──────────┐
│ FRESH │
└────┬─────┘
│ Step 1: POST /api/setup/register
▼
┌─────────────────────┐
│ SUPER USER CREATED │
└────────┬────────────┘
│ Step 2: POST /api/setup/seed
▼
┌─────────────────────┐
│ DATABASE SEEDED │
└────────┬────────────┘
│ Step 3: POST /api/setup/about
▼
┌─────────────────────┐
│ ABOUT CONFIGURED │
└────────┬────────────┘
│ Step 4: POST /api/setup/setting
▼
┌─────────────────────┐
│ SETUP COMPLETE │
│ isSetup() → true │
└─────────────────────┘Flowchart
Middleware Gate
Explanation:
- SetupMiddleware runs first on every request. It checks
IdentifierAppService::isSetup(). - If setup is incomplete, the user is sent to
/setup— regardless of what URL they requested. - If setup is complete, the request passes to MaintenanceMiddleware.
- MaintenanceMiddleware bypasses setup/portal/api routes entirely.
- If maintenance mode is ON, all non-bypassed routes show
UnderConstruction. - If the database is seeded (
isSeeder()), users are redirected to the admin panel at/portal. - Only when all checks pass does the public landing page render.
Auto-Navigation
Explanation:
- The backend returns 8 check items from
getCheckStatuses(). - If
checks[0].status !== 'completed'→ the user must complete Step 1 (Super User). - If any of
checks[1..4](Geographic, Core Settings, Gender, Localization) are pending → Step 2 (Seed). - If any of
checks[5..7](Schools, Articles, About Config) are pending → Step 3 (About/Settings). - If all 8 checks pass → the wizard jumps directly to Step 5 (Done).
SSE Seed Stream
SSE Event Types
| Event | Direction | Payload | When |
|---|---|---|---|
start | Server → Client | { total: 7 } | All seeders begin |
progress | Server → Client | { label, current, total, percent } | Each seeder starts |
seederComplete | Server → Client | { label, current, total } | Each seeder finishes |
checks | Server → Client | { checks: CheckItem[] } | After each seeder (updated system status) |
complete | Server → Client | { message, checks } | All seeders done |
error | Server → Client | { message } | A seeder failed |
SSE State Machine
IDLE → CONNECTING → STREAMING → COMPLETE
│
└── ERROR → IDLE (retry)Store Separation
The seed process uses two stores because they manage different concerns:
seed.stores.ts — Seed process state:
seeding: boolean— is seeding in progress?progress: { label, current, total }— current seeder infochecks: CheckItem[]— latest system statuserror: string | null— error message
seed-sse.stores.ts — SSE connection state:
connecting: boolean— is the HTTP request in flight?connected: boolean— is the stream active?error: string | null— connection error
Why SSE Instead of Polling
The seed process runs 7 seeders sequentially, taking several seconds. Polling would require the frontend to repeatedly ask "is it done yet?" (wasteful and laggy). SSE allows the server to push progress updates as each seeder completes, giving instant UI feedback.
Algorithms
isSetup() — Master Gate Check
File: app/Services/Common/IdentifierAppService.php
This is the single source of truth. Every request hits this check via SetupMiddleware.
public static function isSetup(): bool
{
// Tier 1: Must have at least one admin user
if (! User::query()->exists()) return false;
// Tier 2: Seeded reference data must exist
if (! Setting::query()->where('module', 'core')->where('group', 'location')->exists()) return false;
if (! Gender::query()->exists() || ! Ethnicity::query()->exists()) return false;
if (! Religion::query()->exists()) return false;
if (! Localization::query()->exists()) return false;
// Tier 3: Minimum public content
if (! School::query()->exists() || ! SchoolEducationLevel::query()->exists()) return false;
if (! Article::query()->exists()) return false;
// Tier 4: Branding and identity
if (! Setting::query()->where('module', 'core')->where('group', 'general')->exists()) return false;
if (! Setting::query()->where('module', 'core')->where('key', 'yayasan_name')->exists()) return false;
// Tier 5: Contact and social media
if (! Setting::query()->where('module', 'contact')->exists()) return false;
if (! Setting::query()->where('module', 'social')->exists()) return false;
return true;
}Logic: Each return false acts as a gate. The first missing item immediately rejects. This means the check is O(n) in the worst case but typically fails fast at Tier 1 or 2.
seeder() — Database Readiness
File: app/Services/Common/IdentifierAppService.php
public static function seeder(): array
{
return [
'geographic' => Setting::query()->where('module', 'core')->where('group', 'location')->exists(),
'gender' => Gender::query()->exists(),
'ethnicity' => Ethnicity::query()->exists(),
'localization' => Localization::query()->exists(),
'religion' => Religion::query()->exists(),
];
}Purpose: Returns individual booleans so the frontend can show which seeders have/haven't run.
isSeeder() convenience method:
public static function isSeeder(): bool
{
$s = self::seeder();
return $s['geographic'] && $s['gender'] && $s['ethnicity']
&& $s['localization'] && $s['religion'];
}Why this exists separately from isSetup(): isSeeder() is intentionally less strict. Once the database has its fundamental seed data (regions, genders, religions), the user is redirected to the admin portal even if branding and contact info aren't configured yet. The assumption is an admin can finish via the panel.
initial() — Content Readiness
File: app/Services/Common/IdentifierAppService.php
public static function initial(): array
{
return [
'school' => School::query()->exists() && SchoolEducationLevel::query()->exists(),
'news' => Article::query()->exists(),
'ratings' => Rating::query()->exists(),
'banner' => Banner::query()->exists(),
'announcement' => Announcement::query()->exists(),
'faq' => Faq::query()->exists(),
];
}Purpose: Fine-grained content readiness. Each key maps to a feature that can be independently ready or not. The wizard checks these to determine which steps to show.
Relationship to isSetup():
isSetup()checksinitial()['school']andinitial()['news'](mandatory).initial()['ratings'],['banner'], etc. are not required byisSetup()— they're nice-to-have content that doesn't block the application.
Frontend Auto Navigation
File: resources/js/modules/landing/setup/form/shared/services/step-control-actions.ts
Algorithm
export function computeInitialStep(checks: CheckItem[]): number {
if (checks.length === 0) return 1;
// Step 1: Super User
if (checks[0]?.status !== 'completed') return 1;
// Step 2: Seed (checks 1-4 must ALL be completed)
const seedChecks = checks.slice(1, 5);
if (seedChecks.some((c) => c.status !== 'completed')) return 2;
// Step 3: About/Settings (checks 5-7 must ALL be completed)
const aboutChecks = checks.slice(5, 8);
if (aboutChecks.some((c) => c.status !== 'completed')) return 3;
// Everything complete → Done
return 5;
}Integration in Index.svelte
<script lang="ts">
let currentStep = $state(computeInitialStep(initialChecks));
// ↑ computed immediately
// NOT in a $effect
$effect(() => {
const unsub = stepControl.subscribe((s) => {
currentStep = s.currentStep; // catches subsequent navigation
});
return () => unsub();
});
</script>Critical rule: computeInitialStep() must be called synchronously when the component initializes, not deferred to a $effect. Using $state(1) and updating later causes a render flash and potential SSR hydration mismatch.
Mapping: Backend Checks → Wizard Steps
| Backend Check Index | Check Label | Wizard Step | Failure State |
|---|---|---|---|
| 0 | Super User Account | 1 - Super User | No admin exists |
| 1 | Geographic Data | 2 - Seed | Cities/states not seeded |
| 2 | Core Settings | 2 - Seed | App settings missing |
| 3 | Gender, Ethnicity & Religion | 2 - Seed | Form dropdowns empty |
| 4 | Localization | 2 - Seed | Locales not available |
| 5 | Schools | 3 - About | No school to manage |
| 6 | Articles / News | 3 - About | Public news empty |
| 7 | About Us Configurations | 3 - About | Branding incomplete |
Key Files
Backend
| File | Role |
|---|---|
app/Http/Middleware/Setup/SetupMiddleware.php | Gates every request. Redirects to /setup if isSetup() returns false. Bypasses /setup* and /api/setup*. |
app/Http/Middleware/Setup/MaintenanceMiddleware.php | Second gate. Shows maintenance page or redirects to /portal if seeded. Bypasses /setup*, /portal/*, /api/setup*. |
app/Services/Common/IdentifierAppService.php | Centralized state checks. Contains setup(), seeder(), initial(), isSetup(), isSeeder(), isMaintenance(). |
app/Http/Controllers/Landing/SetupController.php | Serves the Inertia setup page + handles all step APIs (register, seed, about, setting, status). |
routes/api/setup.php | API route definitions for all setup endpoints. |
routes/web/landing/setup.php | Web route for the /setup Inertia page. |
database/migrations/0000_runner_migration.php | Custom migration runner that reads MIGRATION_RUNNER_FOLDERS env. |
Frontend
| File | Role |
|---|---|
resources/js/pages/landing/Setup.svelte | Inertia page component. Receives checks + setupComplete from backend. Renders wizard or completion screen. |
resources/js/modules/landing/setup/form/Index.svelte | Wizard entry point. Computes initial step, renders the correct step component. |
resources/js/modules/landing/setup/form/shared/stores/form-setup-step-control.stores.ts | Step navigation state (next, prev, goToStep, configure). |
resources/js/modules/landing/setup/form/shared/services/step-control-actions.ts | Pure functions: computeInitialStep(), computeStepLabels(). |
resources/js/modules/landing/setup/form/super-user/ | Step 1 — Super user registration with OTP. |
resources/js/modules/landing/setup/form/seed/ | Step 2 — SSE-based database seeding with real-time progress. |
resources/js/modules/landing/setup/form/about/ | Step 3 — Foundation about, contact, social media forms. |
resources/js/modules/landing/setup/form/setting/ | Step 4 — Theme, appearance, contact/social configuration. |
resources/js/modules/landing/setup/tutorial/ | Sidebar tutorial panel showing setup status. |