# Laravel ERP — Installation Wizard
## Plan, Architecture Decisions & All Fixes

---

## 1. What Was Built

A full web-based installation wizard (`POST_ROADMAP FEAT-02`) that lets a client deploy the ERP on any server by visiting `/install` in a browser. No SSH or artisan commands needed for initial setup.

**Commit:** `c152da6` — 22 files, 1 803 insertions  
**Fix 1:** `17ec778` — auto-generate APP_KEY before EncryptCookies runs  
**Fix 2:** `20abd84` — force file cache driver before DB exists + APP_KEY  

---

## 2. Files Created

### Middleware
| File | Purpose |
|---|---|
| `app/Http/Middleware/ForceFileSession.php` | Prepended to web group — forces file session + file cache + generates APP_KEY before any DB exists |
| `app/Http/Middleware/CheckInstalled.php` | Bidirectional lock-file guard — redirects to /install if not installed, redirects away from /install if already installed |

### Controller & Service
| File | Purpose |
|---|---|
| `app/Http/Controllers/Install/InstallController.php` | 6-step wizard controller, thin — all logic in service |
| `app/Services/Install/InstallService.php` | All install logic: requirements check, DB test, .env write, migrate, seed, admin creation, finalize |

### Form Requests
| File | Purpose |
|---|---|
| `app/Http/Requests/Install/DatabaseConfigRequest.php` | Validates DB driver/host/port/name/user/pass |
| `app/Http/Requests/Install/AppConfigRequest.php` | Validates app_name, app_url, timezone, locale |
| `app/Http/Requests/Install/AdminAccountRequest.php` | Validates company, admin name/email/password; `$dontFlash` prevents password session exposure |

### Views
| File | Purpose |
|---|---|
| `resources/views/install/layout.blade.php` | Base layout — local Bootstrap 5 assets, CSRF meta tag, 6-step progress bar |
| `resources/views/install/steps/welcome.blade.php` | Step 1 — PHP/filesystem requirements table with pass/fail badges |
| `resources/views/install/steps/database.blade.php` | Step 2 — driver select, AJAX connection test, continue blocked until test passes |
| `resources/views/install/steps/app-config.blade.php` | Step 3 — app name/URL (auto-detected)/timezone/locale |
| `resources/views/install/steps/migrate.blade.php` | Step 4 — runs migrations + seeders, shows collapsible output |
| `resources/views/install/steps/admin-account.blade.php` | Step 5 — company name, admin credentials, password strength meter |
| `resources/views/install/steps/complete.blade.php` | Step 6 — success screen, post-install checklist |

### Config & Routes
| File | Purpose |
|---|---|
| `config/install.php` | Single source of truth — step toggles, seeder registry, role name, initial settings, license extension point |
| `routes/install.php` | 12 routes under `prefix('install')->name('install.')` — outside locale group, no auth |

### Static Assets (committed, no CDN)
```
public/vendor/installer/
├── bootstrap.min.css
├── bootstrap.bundle.min.js
├── bootstrap-icons.min.css
└── fonts/
    ├── bootstrap-icons.woff
    └── bootstrap-icons.woff2
```

### Modified Files
| File | Change |
|---|---|
| `bootstrap/app.php` | Prepended `ForceFileSession` + `CheckInstalled` to web group; registered install routes in `then:` callback; added `check.installed` alias |

---

## 3. Architecture Decisions

### Session Bootstrapping (Critical)
**Problem:** Default `SESSION_DRIVER=database` + `CACHE_STORE=database` crash on a fresh server with no database.

**Wrong approach:** Registering `ForceFileSession` as a route middleware alias — route middleware runs AFTER the `web` group (after `StartSession`), too late.

**Correct approach:** `$middleware->prependToGroup('web', ForceFileSession::class)` in `bootstrap/app.php`. This inserts it as the FIRST middleware in the web group, before `StartSession` and `EncryptCookies`.

`ForceFileSession` sets both `session.driver` and `cache.default` to `file` in-memory only — no `.env` write needed for these. They revert to the `.env`-configured driver automatically once `.installed` exists.

### Lock File
`storage/.installed` — JSON format with version metadata:
```json
{
    "installed_version": "1.0.0",
    "installed_at": "2026-06-17T12:00:00+00:00",
    "php_version": "8.2.x",
    "laravel_version": "12.x"
}
```
Written by `finalize()`, chmod 0444 (read-only). Checked by `ForceFileSession` and `CheckInstalled`.

### .env Writing
`InstallService::writeEnvValues(array $map)` flow:
1. Copy `.env.example` → `.env` if absent
2. Read current `.env`
3. Validate each key: `/^[A-Z][A-Z0-9_]*$/`
4. Strip newlines from values
5. Quote values that contain spaces, `$`, `#`, `=`, `"`, `'`
6. Regex-replace existing keys or append new ones
7. `file_put_contents(..., LOCK_EX)` — atomic write
8. `Artisan::call('config:clear')` + `route:clear`
9. `config()->set()` for each key in current process (Artisan sub-processes read `.env` directly, current process needs manual update)

### DB Password Security
DB password is **never stored in session**. Written to `.env` immediately after connection test passes. Only `install.driver` is stored in session (for UX prefill if user goes back).

### Admin Creation Guard
`Admin::exists()` check before creating admin prevents reinstallation attack if `.installed` is manually deleted.

### Seeder Registry
`config('install.seeders')` controls which seeders run. `AdminUserSeeder` is deliberately absent (hardcoded credentials). Adding/removing seeders from the install flow requires only changing `config/install.php`.

### Locale Bypass
Install routes are registered in a separate `then:` callback in `bootstrap/app.php`, outside the `LaravelLocalization` prefix group. All routes are flat `/install/*` — no locale prefix.

### Assets Strategy
All Bootstrap 5 + Bootstrap Icons files are committed to `public/vendor/installer/`. No CDN dependency — ERP deployments are often in corporate/restricted networks.

---

## 4. Install Flow

```
GET /install
  └─ ForceFileSession: session=file, cache=file, generate APP_KEY if missing
     └─ CheckInstalled: not installed + on /install → pass through
        └─ Step 1 (welcome): requirements check table
           └─ POST /install/check → redirect to step 2

GET /install/database (requires install.step >= 1)
  └─ AJAX POST /install/test-db: test connection, return JSON
  └─ POST /install/database: test + write DB_* to .env → step 3

GET /install/app (requires install.step >= 2)
  └─ POST /install/app: write APP_NAME, APP_URL, APP_TIMEZONE, APP_LOCALE → step 4

GET /install/migrate (requires install.step >= 3)
  └─ POST /install/migrate:
       ├─ generateKeyIfMissing() — key:generate --force if APP_KEY short/empty
       ├─ migrate --force
       ├─ db:seed RolePermissionSeeder
       └─ db:seed BranchSeeder
     └─ Redirect back to GET /install/migrate (shows results + Continue link)

GET /install/admin (requires install.step >= 4)
  └─ POST /install/admin:
       ├─ Guard: Admin::exists() → throw if any admin exists
       ├─ Admin::create(name, email, Hash::make(password), status=1)
       ├─ assignRole(config('install.super_admin_role'))
       ├─ storeCompanyName() via SettingService
       └─ finalize():
            ├─ storage:link
            ├─ File::ensureDirectoryExists(public/admin/users)
            ├─ seedInitialSettings() — default_currency, default_language, date_format
            ├─ write storage/.installed (JSON)
            ├─ chmod .installed 0444
            └─ config:clear + cache:clear + forgetCachedPermissions()

GET /install/complete → success screen → link to /en/admin/login
```

---

## 5. config/install.php Reference

```php
return [
    'app_version'    => '1.0.0',

    'steps' => [
        'license'       => false,   // set true to enable license key step (future)
        'requirements'  => true,
        'database'      => true,
        'app_config'    => true,
        'migrate'       => true,
        'admin_account' => true,
    ],

    'seeders' => [
        \Database\Seeders\RolePermissionSeeder::class,
        \Database\Seeders\BranchSeeder::class,
        // AdminUserSeeder intentionally absent — hardcoded credentials
    ],

    'super_admin_role' => 'Super Admin',  // must match RolePermissionSeeder

    'initial_settings' => [
        ['key' => 'default_currency', 'value' => 'USD',   'scope' => 'system', 'group' => 'finance'],
        ['key' => 'default_language', 'value' => 'en',    'scope' => 'system', 'group' => 'general'],
        ['key' => 'date_format',      'value' => 'Y-m-d', 'scope' => 'system', 'group' => 'general'],
    ],

    'license' => [
        'enabled'          => false,
        'verification_url' => null,
    ],

    'infrastructure_steps' => false,  // enable in v2 for Redis/queue config
];
```

---

## 6. Bugs Fixed After Initial Commit

### Fix 1 — `MissingAppKeyException` (commit `17ec778`)

**Symptom:** `GET /install` returns 500.  
**Log:** `No application encryption key has been specified.`  
**Cause:** Server `.env` has `APP_KEY=` empty. `EncryptCookies` runs after `ForceFileSession` in the pipeline and needs the `Encrypter` service, which throws on an empty key.  
**Fix:** `ForceFileSession::bootAppKey()` — generates a random key in-memory via `config(['app.key' => $key])` AND writes it to `.env` so it persists across requests.

### Fix 2 — `DatabaseStore` crash from throttle middleware (commit `20abd84`)

**Symptom:** `GET /install` returns 500 after Fix 1.  
**Log:** `Database file at path [.../database.sqlite] does not exist. SQL: select * from "cache"...`  
**Cause:** `throttle:30,1` on install routes uses `RateLimiter` which reads from the configured `CACHE_STORE=database`. That tries to query the `cache` table in the SQLite file. The SQLite file doesn't exist before migrations run.  
**Fix:** `ForceFileSession` now also sets `config(['cache.default' => 'file'])` when not installed.

---

## 7. Deployment Checklist (Live Hosting)

### Before visiting /install
- [ ] Upload all project files (including `vendor/`, or run `composer install --no-dev` on server)
- [ ] Upload `public/vendor/installer/` (Bootstrap assets)
- [ ] Create `.env` from `.env.example` (APP_KEY can be empty — installer generates it)
- [ ] Set `APP_ENV=production` in `.env`
- [ ] Set document root to `public/` in cPanel/Nginx
- [ ] Ensure `storage/` and `bootstrap/cache/` are writable (`chmod -R 775`)
- [ ] Ensure `storage/framework/sessions/`, `storage/framework/cache/`, `storage/framework/views/` exist

### During /install
- Step 1: All requirements must show green. Fix any red items.
- Step 2: Enter MySQL credentials from cPanel → MySQL Databases.
- Step 3: Set `APP_URL` to `https://yourdomain.com` (auto-detected, just confirm).
- Step 4: Click "Run Migrations". Wait for all green. Takes 30–90 seconds.
- Step 5: Enter company name and first admin account credentials.
- Step 6: Done. Click "Go to Admin Login".

### After /install
```bash
# On the server (SSH or cPanel terminal):
php artisan queue:work --daemon   # if using queued jobs
php artisan optimize              # optional: cache config/routes/views for speed
```

- Build frontend assets on dev machine first, commit `public/build/`, deploy. OR run `npm install && npm run build` on the server if Node is available.
- Configure mail in `.env` (MAIL_MAILER, MAIL_HOST, etc.)
- Set up Fiscal Year in Finance → Setup before creating transactions.

### Common Issues
| Error | Fix |
|---|---|
| 500 on /install, log: `MissingAppKeyException` | Covered by `ForceFileSession::bootAppKey()`. If still happening, ensure the updated middleware file is uploaded. |
| 500 on /install, log: `Database file ... does not exist` | Covered by `config(['cache.default' => 'file'])` in `ForceFileSession`. Upload latest file. |
| 500 on /install, log: `Permission denied storage/framework/sessions` | Run `chmod -R 775 storage bootstrap/cache` on the server. |
| Installer page shows but CSS missing | Upload `public/vendor/installer/` directory (Bootstrap assets). |
| Migration step fails: `SQLSTATE[HY000] [2002]` | Wrong DB host. Use `127.0.0.1` instead of `localhost` on some shared hosts. |
| Migration step fails: `Access denied for user` | Wrong DB username/password or user not granted privileges. Check cPanel → MySQL Databases. |
| After install, `/en/admin/login` shows 500 | Run `php artisan optimize` or `php artisan config:clear`. |

---

## 8. Testing Notes

**Test environment bypass:** Both `CheckInstalled` and `ForceFileSession` check `app()->environment('testing')` and return early. `phpunit.xml` sets `SESSION_DRIVER=array` so no session file writing occurs in tests.

**Pre-existing failures (not caused by installer):**
- `AdminAuthTest` — 3 failures (locale prefix mismatch on logout redirect, 302 vs 403 on permission check)
- `AdminDashboardRouteSmokeTest` — 4 failures (locale middleware vs test environment route resolution)

These failures existed before the installer was added (confirmed by stashing installer and re-running tests).
