# Laravel 8 → 12 Upgrade & Modernisation — CGMS

Combat Gym Management System. This document records what changed, why, what was
verified, and what remains outstanding.

---

## 1. Environment

| | Before | After |
|---|---|---|
| Laravel | 8.60.0 | **12.68.0** |
| PHP requirement | `^7.3\|^8.0` | **`^8.2`** (running 8.2.12) |
| Composer | 2.x | 2.10.2 |
| Database | MariaDB 10.4.32 (MySQL driver) | unchanged |
| Frontend build | Laravel Mix 6 / webpack | **Vite 6 + `laravel-vite-plugin`** |
| Node | — | 24.19.0 / npm 11.17.0 |
| Test framework | PHPUnit 9 | PHPUnit 11.5 |

### PHP extensions enabled

`soap`, `sockets`, `zip` and `gd` were commented out in `P:\xampp82\php\php.ini`.
`soap` and `sockets` are **required** by the ZKTeco terminal library; `zip` is
used by Composer. They are now enabled. The original file is preserved at
`php.ini.bak.wclgym`.

---

## 2. Safety: what was backed up before anything changed

Everything is in `P:\xampp82\htdocs\wclgym_backup_20260827_121219\`:

* Full source tree (excluding `vendor/`, `node_modules/`).
* `db_backup.sql` — complete `mysqldump` of the production database.
* `removed/` — every file taken out of the working tree, recoverable as-is.

**No destructive database command was run at any point.** No `migrate:fresh`,
`migrate:refresh`, `db:wipe`, `DROP`, `TRUNCATE`, or `ALTER`. Not a single
migration was executed; the schema is byte-for-byte what it was.

### The database name

The live database is literally named `" weblvavj_cgms"` — **with a leading
space**, an artefact of how the dump was imported locally. `.env` pointed at
`weblvavj_cgms`, so the application could not connect at all.

Rather than rename (risky) or edit `.env` to carry a space (fragile), the dump
was imported into a correctly named `weblvavj_cgms`. The space-named database
is **left untouched** as a second fallback. Row counts, max IDs and money totals
were compared across both and match exactly.

---

## 3. What this application actually is

A gym management system. `profiles.type` distinguishes **1 = Executive** from
**2 = Member**; `package_profile.status` is **1 = running, 0 = previous**.

### Live modules (reachable, working, verified)

| Module | Routes | Tables |
|---|---|---|
| Authentication | `/login`, `/logout` | `users` |
| Dashboard | `/dashboard` | derived |
| Members | `/members*` (index, create, show, edit, activity) | `profiles` |
| Subscriptions | `/members/{id}/subcription*` | `package_profile`, `package_histories`, `packages` |
| Admissions | `/members/{id}/admission*` | `admission_histories` |
| Transaction reports | `/transactionreport`, `/dailytransactionreport`, `/due-transaction-report`, `/membertransactionreport` | `package_*`, `calendar`, `profiles` |
| Attendance reports | `/dailyattendancereport/{1\|2}`, `/employeewiseattendance/{1\|2}` | `attendance_logs`, `profiles`, `rosters`, `shifts`, `late_time_setups` |
| Device sync | `/zklist`, `/postajaxattdevice`, `/memberinactive` | `attendance_logs` |
| Change password | `/change_password` | `users` |

### Dormant legacy HR modules — see §9

An earlier employee-attendance system is still present in the codebase
(employees, departments, salaries, leaves, shifts, rosters, holidays). It was
**already non-functional before this upgrade** and remains so. Nothing was
deleted; details and the exact reasons are in §9.

---

## 4. Dependency changes

| Package | Old | New | Reason |
|---|---|---|---|
| `laravel/framework` | ^8.54 | **^12.0** (12.68.0) | Target of the upgrade |
| `php` | ^7.3\|^8.0 | **^8.2** | Laravel 12 minimum |
| `laravel/sanctum` | ^2.11 | **^4.0** (4.3.3) | Only version compatible with Laravel 12 |
| `laravel/tinker` | ^2.5 | ^2.10 (2.11.1) | Compatibility |
| `guzzlehttp/guzzle` | ^7.0.1 | ^7.9 (7.15.5) | Compatibility; kept on 7.x, which is what Laravel 12 expects |
| `fruitcake/laravel-cors` | ^2.0 | **removed** | Superseded by the framework's own `Illuminate\Http\Middleware\HandleCors` since Laravel 9. `config/cors.php` is unchanged and still honoured |
| `facade/ignition` | ^2.5 | **removed** | Abandoned; incompatible with Laravel 9+. Laravel 12 ships its own error page and uses Collision on the CLI |
| `nunomaduro/collision` | ^5.0 | **^8.6** (8.9.5) | Laravel 12 compatibility |
| `phpunit/phpunit` | ^9.3.3 | **^11.5** (11.5.56) | Laravel 12 test runner |
| `mockery/mockery` | ^1.4.2 | ^1.6 (1.6.15) | Compatibility |
| `fakerphp/faker` | ^1.9.1 | ^1.23 (1.24.1) | Compatibility |
| `laravel/sail` | ^1.0.1 | ^1.41 (1.67.0) | Compatibility |
| `laravel/pint` | — | **added** ^1.18 | Code formatting (§8) |
| `laravel/pail` | — | **added** ^1.2 | Log tailing, part of the Laravel 12 dev toolchain |

Transitively: Carbon 2 → **3.13.2**, Monolog 2 → **3.10.0**, Symfony 5 →
**7.4**, Swift Mailer → Symfony Mailer, `opis/closure` →
`laravel/serializable-closure`.

`composer validate` passes. `composer audit` reports **no security advisories**.
`--ignore-platform-reqs` was never used.

### Frontend

`laravel-mix`, `postcss`, `lodash`, `axios` removed (Mix output was not
referenced by any view). Added: `vite`, `laravel-vite-plugin`, `jquery` 3.7,
`datatables.net` 2, `datatables.net-buttons` 3, `bootstrap-datepicker` 1.10,
`jszip`, `pdfmake`, `@fortawesome/fontawesome-free` 6.

`npm audit` reports **0 vulnerabilities**.

---

## 5. Code changes

### 5.1 PSR-4 violations — the biggest finding

Composer reported, and testing confirmed, that **20 classes were not
autoloadable at all**:

* 9 models in `app/Models/` declared `namespace App;` (`Department`,
  `Employee`, `EmpLeaves`, `EmployeePackage`, `EmployeeSalary`,
  `LateTimeSetup`, `OfficeMonthlyHoliday`, `Roster`, `Shift`).
* 11 controllers in `app/Http/Controllers/Admin/` declared
  `namespace App\Http\Controllers;`.

Composer's optimised classmap **skips** classes whose namespace does not match
their path, so `class_exists('App\Department')` returned `false`. Every route
touching them was a fatal error.

Fixed by moving each class into the namespace matching its directory and
rewriting every reference across `app/`, `routes/`, `config/` and the views.
`composer dump-autoload -o` is now warning-free.

### 5.2 Routes

`routes/web.php` used string controller references (`'EmployeeSalaryController'`,
`'DepartmentController@getDepartment'`) which depend on
`RouteServiceProvider::$namespace` — commented out in this project, and removed
from the default skeleton after Laravel 8. All references are now FQCN arrays
(`[EmployeeSalaryController::class, 'index']`).

**Every URL, HTTP verb and route name is unchanged**, with one deliberate
exception: `GET /login` and `POST /login` both carried the name `login`, which
made `php artisan route:cache` throw
`Unable to prepare route [login] for serialization`. The POST route is now named
`login.store`. `route('login')` resolves to the same `/login` URL as before, so
no template or redirect changed behaviour.

`RouteServiceProvider::HOME` was `/home` — a route that has never existed in
this application, so an already-signed-in visitor hitting `/login` was sent to a
404. It now points at `/dashboard`.

### 5.3 The ZKTeco terminal — the crash that blocked member registration

Three separate defects:

1. **`app/Http/Controllers/Controller.php`** — the base controller's constructor
   ran `new Zk_class($ip)`, which `include`d (not `include_once`) nine library
   files. The second controller instantiated in any one PHP process therefore
   died with *"Cannot declare class TADPHP\TADFactory, because the name is
   already in use"*. This is why `php artisan route:list` had never worked.
2. **`$this->emp_data` was always `null`** (the assignment was commented out),
   so `MemberController::setPin2()` called `set_user_info()` on null.
   **Creating a member was a guaranteed 500 error.** The same held for
   `AjaxController::postAttDevice()` and the every-minute scheduled sweep.
3. `TAD::__call()` sends commands to an unreachable device anyway and returns a
   SOAP fault that still looks like a response, so "did it work?" could not be
   answered.

Replaced with `App\Services\ZkDeviceService`, plus `AttendanceSyncService` and
`MembershipExpiryService`:

* The library is loaded by **PSR-4 autoloading** (`"TADPHP\\":
  "app/Libraries/zk-class/lib/"`), never by manual `include`.
* Connection settings moved to `config/zk.php` / `ZK_DEVICE_*` env vars. The
  default is the IP that was hard-coded in the controller, so behaviour is
  unchanged where the device is reachable.
* Every command is gated on a cached `is_alive()` check, so an offline device
  is reported as offline instead of silently "succeeding".
* **Member registration no longer fails when the terminal is down.** The PIN is
  allocated from `MAX(profiles.pin2) + 1` — the same intent as the original
  `$maxPIN2 + 1` — and the operator is told the member still needs enrolling.
* **The expiry sweep is skipped entirely when the device is unreachable**, so a
  member is never flagged inactive while their fingerprint still opens the door.
  This preserves the original retry-next-minute semantics.

`app/Libraries/Zk_class.php` is now unreferenced and has been moved to
`backup/removed/`.

### 5.4 Business-logic corrections

Two genuine defects were fixed; everything else was preserved exactly.

**Pivot lookup after `attach()`** — `MemberController::insertPackageData()`
found the row it had just inserted with
`orderBy('pivot_created_at', 'desc')`. `created_at` on that row is set to the
**user-supplied payment date**, so back-dating a payment attached the ledger
entry to an *older* subscription. Now ordered on the pivot's auto-increment id.
Covered by `SubscriptionPaymentTest::test_the_opening_payment_is_attached_to_the_new_subscription_even_when_back_dated`.

**Missing transactions** — member creation, subscription renewal and payment
recording each wrote to two or three tables with no transaction. All three are
now wrapped in `DB::transaction()`. The attendance import's delete-then-reinsert
is likewise transactional, so an interrupted sync can no longer wipe a day's
punches.

**Explicitly preserved, unchanged:**

* `due = amount − discount − paid`, always recomputed server-side and never
  taken from the submitted `due` field (this was already the behaviour).
* Subscription end date = start date + package duration in months.
* Admission validity = admission date + 1 year.
* Renewal closes **all** of that member's subscriptions (`status = 0`) before
  opening the new one.
* Active/inactive membership defined by `MAX(package_profile.status)` per
  profile.
* Report filters: `profiles.status = 1 AND profiles.type = 2` throughout.
* `config/app.php` timezone stays **UTC**. Several models call
  `date_default_timezone_set('Asia/Dhaka')` mid-request; changing the app
  timezone would silently shift the meaning of stored timestamps. See §10.

### 5.5 Other compatibility work

* `app/Http/Kernel.php` — `Fruitcake\Cors\HandleCors` → `Illuminate\Http\Middleware\HandleCors`; `$routeMiddleware` → `$middlewareAliases`.
* `app/Console/Kernel.php`, providers, `Handler` — Laravel 10+ native return types.
* `config/app.php` — `providers`/`aliases` now built from
  `ServiceProvider::defaultProviders()` / `Facade::defaultAliases()`, so future
  framework additions are picked up automatically.
* Removed the dead `use Illuminate\Support\Facades\Input;` (deleted in Laravel 6).
* The scheduler's `$schedule->call('...AjaxController@memberInactive')` is now a
  first-class command, `members:deactivate-expired`, with
  `withoutOverlapping()`. The old `registered:punch` schedule entry was dropped:
  its `handle()` only printed a line to stdout. The command itself is retained.

**The application deliberately keeps the Laravel 8-style skeleton**
(`bootstrap/app.php` + `app/Http/Kernel.php` + `app/Console/Kernel.php`).
Laravel 11/12 fully support this; the slim skeleton is optional. Not
restructuring avoided a large, untestable change with no functional benefit.

---

## 6. Database changes

> **Update (27 Aug 2026):** the live database's *data* was subsequently replaced
> with a demo dataset at the owner's request — see §15. Everything below about
> the **schema** still holds: nothing was migrated, dropped or altered. The
> pre-wipe data is backed up and was verified restorable.

**No schema change.** No migration was created or run. No column, index,
constraint, default or engine was altered. The upgrade is entirely at the
application layer.

Two databases were *created* (nothing was modified):

* `weblvavj_cgms` — correctly named copy of the production data (§2).
* `weblvavj_cgms_test` — clone used by the test suite, so tests never touch
  live data.

Recreate the test database with:

```bash
mysqldump -uroot --databases " weblvavj_cgms" > dump.sql
sed 's/` weblvavj_cgms`/`weblvavj_cgms_test`/g' dump.sql | mysql -uroot
```

### Integrity verified after the upgrade (before the demo reseed)

| Metric | Before | After |
|---|---|---|
| `profiles` / `package_profile` / `package_histories` | 556 / 742 / 777 | **556 / 742 / 777** |
| `admission_histories` / `attendance_logs` | 206 / 10840 | **206 / 10840** |
| `packages` / `departments` / `shifts` / `calendar` / `late_time_setups` | 7 / 9 / 103 / 1826 / 15 | **identical** |
| MAX ids (profiles, pp, ph, ah, al) | 556, 742, 777, 206, 10840 | **identical** |
| `SUM(amount, discount, total_paid, total_due)` | 3832000.00, 799748.00, 2925763.00, 106489.00 | **identical** |
| `package_histories SUM(paid, due)` | 2925763.00, 196489.00 | **identical** |
| Subscription status split | 583 previous / 159 running | **identical** |
| Members by type/status | 7 exec, 545 active, 4 inactive | **identical** |
| Foreign keys | 2 | **2, 0 orphan rows** |

### Indexing

No indexes were added. `attendance_logs` (10.8k rows) has only a primary key and
is queried by `att_date` and `pin2`; `package_profile` is queried by
`profile_id` and `status`. At current volumes the queries return in
milliseconds, so adding indexes would not be justified by observed behaviour.
Revisit if `attendance_logs` grows past a few hundred thousand rows — the
candidates are `(att_date, pin2)` and `package_profile(profile_id, status)`.

---

## 7. UI changes

The old interface was the **Ace admin template** (Bootstrap 3, jQuery 2.1.4,
Font Awesome 4 loaded from the now-dead MaxCDN). Replaced with a purpose-built
design system compiled through Vite. No CSS framework is vendored — the
stylesheet is roughly 137 KB (33 KB gzipped) and contains only what the
application uses.

### Design system — `resources/css/`

* `tokens.css` — the single source of truth: a restrained slate-indigo brand
  ramp, a neutral scale, semantic status colours, an 8-step type scale, a 4px
  spacing scale, radii and elevation. Includes a full dark theme that follows
  the operating system preference and can be overridden with `data-theme`.
* `base.css` — reset, typography defaults, always-visible focus rings,
  `prefers-reduced-motion` support, skip link.
* `layout.css` — the app shell.
* `components.css` — cards, KPI tiles, buttons, forms, tables, badges, alerts,
  accordions, modals, toasts, the auth screen.
* `vendor.css` — DataTables and bootstrap-datepicker restyled onto the tokens
  so nothing looks bolted on.

### Application shell

Collapsible dark sidebar (state remembered in `localStorage`, guarded against
browsers that block it) → sticky top bar with a user menu → breadcrumb and page
header → content. On tablet and phone the sidebar becomes an overlay drawer with
a backdrop, closable with Escape.

**The sidebar is permission-aware**: it is generated from the same
`config/access.php` rule the server enforces, so it can never offer a link the
request would reject.

### Dashboard

Was a single `<h1>Welcome to Dashboard</h1>`. Now shows **real data only**:
active members, collected today/this month, outstanding due, check-ins today, a
14-day collections bar chart, membership status breakdown, and the most recent
payments. Every figure is computed by `App\Services\DashboardMetrics` using the
**same definitions the corresponding reports use** — a test asserts the
dashboard's outstanding-due figure equals the Due List report's total.

### Tables and forms

Tables use DataTables 2 with search, pagination, column visibility and
Excel/PDF/print export, restyled to the design system; numeric columns are
tabular-aligned and right-justified; status is a badge; every list has an empty
state. Long forms are split into labelled sections with required indicators,
inline errors, `aria-invalid` / `aria-describedby` wiring, and an error summary
at the top of the page. Submit buttons show a loading state and **block
double-submission** — a real risk on the payment forms.

### Views rewritten

`layouts/dashboard`, `partials/sidebar`, `partials/topbar`, `auth/login`,
`dashboard/index`, all 8 member views, both subscription includes, all 6 live
report views, `zk/zk`, and a new `user/change-password`. Seven reusable Blade
components were added (`card`, `alert`, `page-header`, `stat`, `badge`, `empty`,
`field`, `photo`).

**Every Blade variable, form field name, form action and validation key was
preserved.** The pricing arithmetic in the subscription forms
(amount/discount/paid/due) is a line-by-line port of the original inline
scripts.

### Accessibility

Semantic landmarks, skip link, visible focus rings, labelled controls, ARIA only
where it earns its place, keyboard-dismissable menus/modals/drawer, status
conveyed by text as well as colour, and `prefers-reduced-motion` honoured.

### Responsive

Breakpoints at 1024px (drawer sidebar), 900px and 640px (form columns collapse,
KPI grid reflows). Wide tables scroll inside their own container rather than
breaking the page. **Verified by CSS review and by inspecting served markup —
not on physical devices.** See §10.

---

## 8. Security improvements

| Area | Before | After |
|---|---|---|
| **Authorization** | The one restricted account was limited by *hiding sidebar links*. Typing a URL reached everything. | `EnsureModuleAccess` middleware enforces the same rule server-side on every route. Covered by 8 tests, including direct POSTs. |
| **Login brute force** | Unlimited attempts | `throttle:login` — 5/min per email+IP and 20/min per IP |
| **Password change** | `min:6`, no confirmation | `Password::min(8)`, `confirmed`, current password checked with the `current_password` rule, session ID regenerated afterwards |
| **File uploads** | Extension taken from the client filename; name `date('Ymdhis').ext` (collides within a second) | Extension derived from the **validated MIME type**, so an executable cannot land in a web-served directory; name includes 10 random characters |
| **Upload paths** | CWD-relative `is_file()` / `move()` | Resolved through `public_path()`; `App\Support\Media` also refuses any stored name containing a path separator (traversal guard, tested) |
| **Mass assignment** | `$request->all()` merged repeatedly into new `Request` objects | Explicit payload arrays; `$fillable` unchanged |
| **Input validation** | `postajax`, `postajax_set_rschedule`, `admissionStore` accepted unvalidated input | All validated; `admissionStore` now checks `profile_id` exists |
| **XSS** | `$.notify` and the device-sync screen wrote server strings via `innerHTML` | Toasts and status messages use `textContent`; the AJAX report fragment is server-rendered Blade (escaped) |
| **CSRF** | `$('form').append(csrf_field())` bolted a token onto *every* form including GET forms | Removed; forms carry `@csrf` properly, AJAX uses the header |
| **Old asset surface** | jQuery 2.1.4, Bootstrap 3, and ~16 MB of template JS/CSS served publicly | Removed from `public/` |
| **Error handling** | Whoops/Ignition | Laravel 12 handler; `user_id` and URL added to log context; `dontFlash` extended with `old_password` |
| **HTTPS** | — | `APP_FORCE_HTTPS` (opt-in, because the host's TLS setup is unknown) |
| **Robots** | — | `noindex, nofollow` on the admin pages |

`config/cors.php` is unchanged and still applies to `api/*` and
`sanctum/csrf-cookie` only. Sanctum 4 is installed and its route
(`sanctum/csrf-cookie`) resolves; the single API route `GET /api/user` is
unchanged.

---

## 9. Dormant legacy HR modules

An earlier employee-attendance product is still in the codebase. It was
**already broken before this upgrade** — verified three independent ways:

1. **Classes could not autoload** (§5.1) — the routes were fatal errors.
2. **Backing tables do not exist** in this database: `employees`,
   `employee_salaries`, `emp_leaves`, `office_monthly_holidays`,
   `employee_attendances`, `employee_packages`.
3. **Views do not exist**: `admin.department.*`, `admin.salary.*`,
   `admin.leaves.*`, `admin.content.default-content`,
   `admin.roster.roster_setup_time`, `admin.shift.shift_setup_time`. The
   orphaned `admin/employee/*` templates extend `admin.employee_attendance`,
   which has never existed here.

Also missing: `App\Mail\SalaryReportMail`, `App\Mail\DailyAttendanceMail` and
their `admin.emails.*` templates, so `/salaryreportmail` and
`/dailyattendancemail` cannot work.

**Nothing was deleted.** The controllers, models and routes are retained and are
now at least syntactically sound and autoloadable. The affected routes are
`salaries.*`, `employees.*`, `departments.*`, `/attendancelist`,
`/saveattendance`, `/updateattendance`, `shiftsetup`, `rostersetup`,
`employee-leave`, `employee_leave/*`, `leave_edit/*`, `leave_delete/*`,
`/employeessalary`, `/dailyattendancesummaryreport`, `/salaryreportmail`,
`/dailyattendancemail`, `departmentlist`, `employeelist`, `salaryslist`.

They are not exposed in the navigation (they were not before, either). Restoring
them would need the missing tables, views and mail classes — a separate piece of
work, and a decision for the business rather than for this upgrade.

---

## 10. Testing

`php artisan test` — **61 passed, 166 assertions**. Tests run against
`weblvavj_cgms_test`; write tests are wrapped in `DatabaseTransactions` and roll
back.

> **Run `php artisan config:clear` before testing.** A cached config file
> overrides the values `phpunit.xml` sets, which would point the suite at the
> production database. `Tests\TestCase` now refuses to run against any database
> other than `weblvavj_cgms_test` and says so explicitly, so this fails loudly
> rather than silently writing to live data.

| Suite | Tests | Covers |
|---|---|---|
| `AuthenticationTest` | 8 | Login, invalid credentials, validation, guest redirects, logout, rate limiting |
| `AuthorizationTest` | 8 | Restricted account blocked from members/finance/device by GET **and** POST; sidebar matches enforcement; unrestricted access intact |
| `ModuleSmokeTest` | 13 | Every screen the sidebar exposes, against real data |
| `MemberRegistrationTest` | 9 | Member + subscription + opening payment; the `due` rule; end-date rule; PIN fallback; validation rejects incomplete data without writing; admission validity |
| `SubscriptionPaymentTest` | 5 | Payment reduces due and appends to the ledger; renewal closes the old subscription; back-dated payment attaches correctly |
| `ReportCalculationTest` | 6 | Report totals compared against independent SQL |
| `MediaTest` | 5 | Upload path resolution, fallbacks, traversal refusal |
| `ZkDeviceServiceTest` | 7 | Offline degradation for every device operation |

### Manual verification over HTTP

Signed in through the real login form against the **live** database, with
`config`/`route`/`view`/`event` caches active, and walked every screen:

```
/dashboard /members /members/{1,0}/activity /members/create
/members/{id} /members/{id}/edit/1 /members/{id}/subcription/1
/members/{id}/subcription-history /members/{id}/admission-history
/members/{id}/admission/1 /transactionreport /dailytransactionreport
/due-transaction-report /membertransactionreport
/dailyattendancereport/{1,2} /employeewiseattendance/{1,2}
/zklist /change_password
```

All **200**, no failures. POST flows exercised: date-range report, daily
attendance report, and the member-transaction AJAX fragment. Built CSS/JS and a
member photo were fetched and served correctly (200, correct content types).

### Report figures cross-checked against SQL

| Check | Report | SQL | Match |
|---|---|---|---|
| Apr 2022 subscriptions | 173,000.00 | 173,000.00 | ✅ |
| Apr 2022 collections | 99,400.00 | 99,400.00 | ✅ |
| Attendance 17-04-2022 | 1 present / 544 absent / 545 register | 1 | ✅ |
| Attendance 25-04-2022 | 0 present | 0 (last punch is 17-04-2022) | ✅ |
| Due List total | matches | matches | ✅ |

### Device endpoints with the terminal unreachable

```
POST /postajaxattdevice → 200 {"status":"failur","msg":" Branch was not connected..."}
GET  /memberinactive    → 200 {"status":"failur","deactivated":0,"skipped":0}
```

Both degrade gracefully; `deactivated: 0` confirms no subscription is closed out
without the device confirming the revocation. Before the upgrade both were fatal
errors.

### Logs

`storage/logs/laravel.log` contains no errors after the fixes landed — only the
expected `ZK terminal unreachable` warnings. The earlier entries
(`opis/closure` deprecation, `TADFactory` double-declaration, the duplicate
`login` route name) are the defects that were fixed.

---

## 11. Performance

| Screen | Before | After | Why |
|---|---|---|---|
| Member Due List | 556 queries (one per member) | **1 query** | Single grouped query with `HAVING SUM(total_due) > 0` |
| Date-range transactions | 2 queries per day (~60/month) | **2 queries** | Grouped by day, joined in PHP |
| Member lists | ~545 lazy-loaded relations | **2 queries** | `with('inactivePackageProfile')` |
| ZK operations | one network round trip per command | **one per request** | `is_alive()` cached |
| JS bundle | 2.6 MB | **295 KB** (96 KB gzipped) | `jszip`/`pdfmake` split into chunks loaded only when someone clicks Excel or PDF |
| Old public assets | ~16 MB served | **removed** | Superseded by the Vite build |

Measured: the Due List test went from 2.58 s to 0.16 s.

---

## 12. Production checklist

```bash
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan optimize          # config + routes + views + events
```

All four caches build successfully. Set in `.env`:

```env
APP_ENV=production
APP_DEBUG=false
SESSION_SECURE_COOKIE=true    # once served over https
APP_FORCE_HTTPS=true          # once served over https
```

Scheduler entry (unchanged cadence):

```
* * * * * cd /path/to/wclgym && php artisan schedule:run >> /dev/null 2>&1
```

`APP_DEBUG` is currently `true` because the local environment is `local`.

---

## 13. Remaining issues and limitations

1. **The ZKTeco terminal (103.109.237.91) is not reachable from this machine**,
   so device enrolment, live attendance import and the expiry sweep could only
   be verified in their *offline* paths. The online paths are unchanged in
   intent but have not been exercised against real hardware.
2. **Responsive layout was verified by CSS review and served markup, not on
   physical devices or in a real browser.** No browser automation was available
   here. Likewise, "browser console errors" could not be observed directly —
   though every asset the pages reference was fetched and returned 200.
3. **Legacy HR modules remain non-functional** (§9). This is pre-existing.
   Deleting them is a business decision and was deliberately left alone.
4. **`app.timezone` is UTC while several models call
   `date_default_timezone_set('Asia/Dhaka')` mid-request.** Timestamps written
   by different code paths therefore mean slightly different things. Changing
   this alters the interpretation of existing data and needs a deliberate
   migration, so it was left exactly as found.
5. **The live database name still has a leading space.** A correctly named copy
   is now in use locally; the production host should be checked for the same
   quirk before deploying.
6. **No email is configured** (`MAIL_HOST=mailhog`), and the two mail routes
   lack their classes (§9).
7. **`guzzle` 8 and `tinker` 3 are available** but were not adopted: Laravel 12
   expects Guzzle 7.x, and Tinker 3 was not needed.
8. **The project is not under version control** (`git init` was not run, as that
   was outside the requested scope). The pre-upgrade state is preserved in the
   backup directory instead.

---

## 14. Upgrade checklist

```
[x] Existing architecture analysed
[x] Database schema analysed
[x] Data flow analysed
[x] Routes inventoried (81 routes)
[x] Modules inventoried (live vs dormant)
[x] Baseline functionality checked
[x] Existing tests executed (none existed beyond stubs; suite written)
[x] PHP upgraded to 8.2
[x] Composer dependencies upgraded
[x] Laravel upgraded to 12
[x] Deprecated packages removed/replaced
[x] Authentication tested
[x] Authorization tested (and enforced server-side for the first time)
[x] CRUD tested
[x] Reports tested and cross-checked against SQL
[x] APIs verified (single Sanctum route, unchanged)
[x] Database integrity verified
[x] UI redesigned
[~] Responsive behaviour verified — CSS/markup only, no device testing
[x] Security audit completed
[x] Performance review completed
[x] Production cache commands verified
[x] Documentation completed
```

---

## 15. Demo dataset (27 Aug 2026)

At the owner's request, every business record in the live database was replaced
with a generated demo dataset. **The schema was not touched** — tables were
emptied, never dropped or altered — and `migrations` was deliberately preserved
so `php artisan migrate:status` still reports correctly.

### Backup taken first, and verified

`P:\xampp82\htdocs\wclgym_backup_prewipe_20260827_135518\weblvavj_cgms_prewipe.sql`
(1.3 MB, all 16 tables).

The dump was not merely written — it was **restored into a scratch database and
compared row by row** against the live one before anything was emptied:

| | Live (pre-wipe) | Restored from backup |
|---|---|---|
| profiles / package_profile / package_histories | 556 / 742 / 777 | **556 / 742 / 777** |
| admission_histories / attendance_logs | 206 / 10840 | **206 / 10840** |
| users / packages / departments / shifts / calendar | 3 / 7 / 9 / 103 / 1826 | **identical** |
| `SUM(amount, total_paid, total_due)` | 3832000.00 / 2925763.00 / 106489.00 | **identical** |

The original data therefore survives in **three** places:

1. the verified dump above,
2. `wclgym_backup_20260827_121219/db_backup.sql` (taken before the upgrade),
3. the untouched, space-named `" weblvavj_cgms"` database,
4. and `weblvavj_cgms_test`, which the test suite uses and which was **not**
   reseeded — so the regression tests still run against the original data.

### Restoring the real data

```bash
mysql -uroot < "P:\xampp82\htdocs\wclgym_backup_prewipe_20260827_135518\weblvavj_cgms_prewipe.sql"
php artisan optimize:clear
```

That drops and recreates `weblvavj_cgms` exactly as it was.

### How the demo data is generated

`database/seeders/DemoDataSeeder.php`, run with `php artisan db:seed`. It is
repeatable: it empties the business tables and rebuilds them from scratch every
time, so re-running gives a fresh dataset rather than duplicates.

**It is destructive by design — back up before running it.**

### What was generated

| Table | Rows |
|---|---|
| `users` | 3 |
| `packages` | 7 (the real price list — master data, not invented) |
| `profiles` | 72 — 8 executives, 64 members |
| `package_profile` | 119 subscriptions |
| `package_histories` | 141 payments |
| `admission_histories` | 68 |
| `attendance_logs` | 2,974 punch records (last 90 days, Fridays closed) |
| `calendar` | 1,827 (2024-01-01 → 2028-12-31) |
| `departments` / `shifts` / `late_time_setups` | 5 / 6 / 12 |
| `migrations` | 10 — **preserved, not reseeded** |

Money summary: 1,055,500 billed · 48,300 discount · 942,900 collected ·
64,300 outstanding across 21 members. Membership split: 40 active / 20 lapsed.

Names, occupations and addresses are Dhaka-appropriate to match the business.
Roughly 30% of profiles reference photos that already exist under
`public/assets/uploads/`, so the UI is not all placeholders.

### Login credentials

| Email | Password | |
|---|---|---|
| `admin@cgms.com` | `password` | full access |
| `manager@cgms.com` | `password` | full access |
| `frontdesk@cgms.com` | `password` | full access |

These are demo credentials. **Change them before this database is used for
anything real.**

### Consistency guarantees, verified by query

The dataset is not random noise — the reports total it, so it has to reconcile:

| Check | Result |
|---|---|
| `total_due = amount − discount − total_paid` on every subscription | **0 mismatches** |
| `SUM(package_histories.paid)` per subscription = `total_paid` | **0 mismatches** |
| Orphaned subscriptions / payments / admissions | **0** |
| Attendance rows with no matching `profiles.pin2` | **0** |
| Members with more than one running subscription | **0** |
| Subscriptions starting before the member registered | **0** |
| Payments, registrations, admissions or punches dated in the future | **0** |

An earlier draft chained renewals *forwards* from the registration date, which
pushed payment dates into 2028. Cycles are now anchored backwards from today, so
the newest subscription sits near the present and history trails behind it.

### Verified after seeding

* Schema compared column by column against the pre-wipe backup —
  **136 columns identical**; both foreign keys present; `migrations` intact.
* All 21 application screens returned 200 over HTTP, signed in as
  `admin@cgms.com`, in 0.16–0.26 s each.
* Dashboard KPIs cross-checked against SQL — active members **40**, collected
  today **3,700.00**, outstanding **62,700.00**, check-ins **27** — all exact.
* Write flows exercised end to end: creating a member produced the correct
  `due = 5500 − 500 − 3000 = 2000`, allocated `pin2 = max + 1`, and wrote the
  opening ledger row; recording a 1,200 payment moved paid to 4,200 and due to
  800 with a second ledger row. The test member was removed afterwards.
* `php artisan test` — **61 passed**, unchanged (the test database was not
  reseeded).
