# Axioma

PHP-based management system for an education center: students, teachers, groups, schedules,
tariffs/payments, and role-based dashboards. See [README.md](README.md) for local setup.

## Stack

Laravel (PHP 8.4), MySQL 8.0, Redis (sessions/cache), Docker/docker-compose. Composer-managed
(`laravel/composer.json`). The app used to be a strangler-fig migration running a custom PHP
framework and Laravel side by side; the legacy half was fully decommissioned once every route had
moved over — everything under `laravel/` is now the whole app.

## Request flow

`.htaccess` at the repo root serves real static files directly (`css/`, `js/`, `images/`) and
routes everything else to [laravel/public/index.php](laravel/public/index.php) — standard Laravel
front-controller dispatch from there. Every route lives in
[laravel/routes/web.php](laravel/routes/web.php) and runs under Laravel's own `web` middleware
group uniformly (sessions, CSRF, `$errors`/`old()`) — the earlier two-file split
(`legacy_bridge.php`/`legacy_bridge_web.php`, session-only vs. full `web` group) existed only
because authentication itself used to run on a separate bridged session; once auth moved onto
Laravel's own session (Phase 62), that split had no more reason to exist and both files were
merged into this one. URL paths still look like the original legacy ones (e.g.
`/group_admin_info.php?group_id=5`, `/admin/teacher_admin_info.php`) — they were preserved exactly
across the migration rather than redesigned, so bookmarks/links didn't need to change.

**Adding a route:** add the controller method, then register it in `laravel/routes/web.php`.

## Layered structure

```
laravel/app/Http/Controllers/   One class per resource, extends the (empty) base Controller
laravel/app/Models/              Eloquent models
laravel/resources/views/         Blade templates, mirror controller/resource names
laravel/app/Support/             Small per-consumer classes (SessionAuth, Iin, Weekdays, XlsxReader)
                                  — copied rather than shared across controllers in some cases,
                                  since each was ported independently; check for an existing copy
                                  before adding a new one
laravel/app/Services/            Cross-cutting logic (Gamification, AdviserTaskService, LessonSubmissionService)
laravel/app/Console/Commands/    Scheduled/CLI jobs (billing:charge-students, billing:send-payment-reminders)
scripts/                         One-off SQL migrations (not a migration framework — see below)
```

Roles: `admin`, `teacher`, `adviser`, `manager`, `parent`, `student`. Entry points per role are
listed in [README.md](README.md#roles) — note that `manager`'s dashboard URL doesn't currently
resolve to a real page (flagged, not yet fixed; see the README table). The authoritative
role→dashboard mapping is `AuthController::ROLE_DASHBOARDS` in
[laravel/app/Http/Controllers/AuthController.php](laravel/app/Http/Controllers/AuthController.php).

## Controller conventions

Controllers extend an empty base `Controller` — no shared helper methods (`render()`,
`verifyCsrf()`, etc. from the old framework don't exist here; use Laravel's own equivalents
directly). The dominant pattern, consistent across most controllers (see
[laravel/app/Http/Controllers/HolidayController.php](laravel/app/Http/Controllers/HolidayController.php)
for a representative example):

1. `SessionAuth::check()` — redirect to `/login.php` if not authenticated
2. an inline role guard (`if (! in_array(SessionAuth::role(), [...], true))`) — soft-redirect with
   a flash message on failure, not a hard 403, matching this app's established style
3. Laravel's own `$request->validate([...])` for pages that read Laravel's own `$errors`/`old()`
   bag, or a manual `Illuminate\Support\Facades\Validator::make(...)` + explicit
   `session()->flash('error', ...)` for pages whose view doesn't render that bag (check which the
   page you're editing actually does — using `$request->validate()` where the view doesn't display
   `$errors` silently swallows validation failures)
4. the actual DB work (Eloquent, or raw `DB::table()`/`DB::select()` for bespoke report-style
   queries that don't map cleanly onto Eloquent)
5. `session()->flash(...)` + `redirect(...)`

Follow the existing pattern in the controller you're editing rather than introducing a new one.

## Database

Eloquent, configured via [laravel/config/database.php](laravel/config/database.php) +
`laravel/.env`. No raw PDO singleton — use models or the `DB` facade.

There's no migration framework in the Laravel sense either — schema changes still ship as
numbered/described `.sql` files in `scripts/` (e.g. `add_category_to_expenses.sql`), applied
manually. When adding a column/table, add a new `scripts/*.sql` file rather than editing an
existing one, and mention the manual-apply step in the commit/PR.

## Auth & security

- `App\Support\SessionAuth` ([laravel/app/Support/SessionAuth.php](laravel/app/Support/SessionAuth.php))
  — a thin wrapper around Laravel's own `Auth` facade/session guard (Phase 62). `check()`/`id()`/
  `role()`/`user()` delegate straight to `Auth::`; `login()`/`logout()` are the only writers. Kept
  as a separate class (rather than switching every call site to `Auth::` directly) purely so its
  ~167 read-only call sites across the app stay untouched — there's no other reason to route
  through it now that it isn't bridging a separate legacy session anymore.
- `App\Models\User` is Laravel's stock `Authenticatable` model, with `getRememberTokenName()`
  overridden to return `''` — this app's 30-day remember-me cookie (`AuthController`'s own
  `bin2hex(random_bytes(32))` token + `remember_expires` column, entirely hand-rolled) manages
  `users.remember_token` itself, so Laravel's native remember-token cycling is disabled to avoid
  the two fighting over the same column. Laravel's native remember-me (`Auth::login($user,
  remember: true)`, ~5-year cookie, rotates on every use) is deliberately not used — different
  semantics than the existing 30-day hard cutoff.
- Cookie is scoped to `.axioma-study.kz` in production so the session is shared across subdomains
  — `game.axioma-study.kz` (where students land after login) is the same app under a different
  vhost, and needs the same session cookie to keep them logged in. Controlled by
  `SESSION_COOKIE_DOMAIN` (blank for local dev), read by `laravel/config/session.php`'s `domain`
  key.
- CSRF: Laravel's own `VerifyCsrfToken` middleware (part of the `web` group, applied uniformly to
  every route) + `@csrf` in real forms, or an `X-CSRF-TOKEN` header (read from the
  `<meta name="csrf-token">` tag in `layouts/app.blade.php`) for JS-only `fetch`/`$.ajax` call
  sites with no backing form.
- Passwords: `password_hash()`/`password_verify()` via Laravel's `Hash` facade (`Auth::attempt()`
  for login).
- All queries are parameterized (Eloquent/query builder) — never interpolate user input into SQL.

## Validation

Laravel's own `$request->validate([...])` / `Illuminate\Support\Facades\Validator::make(...)` —
see Controller conventions above for which one to use depending on the page. Error messages are in
Russian (the whole UI is Russian-facing) — match that when adding new rules or flash messages.

## Views

Blade templates under `laravel/resources/views/{resource}/{action}.blade.php`, extending
`layouts/app.blade.php` for shared chrome (header/sidebar/footer, via `HeaderComposer`) and using
Bootstrap 5 classes.

## Tests

`laravel/tests/Unit` + `laravel/tests/Feature` (PHPUnit). CI ([.gitlab-ci.yml](.gitlab-ci.yml))
runs `php -l` lint, the test suite, and a Docker build; the `phpunit` job brings up a `mysql:8.0`
service, because the feature tests need a real database.

**Feature tests run against real MySQL, not sqlite `:memory:`** — `laravel/phpunit.xml` pins
`DB_CONNECTION=mysql` and `DB_NAME=axioma_test`. sqlite was what the suite used until it was
abandoned for a concrete reason: the app leans on raw MySQL (`CURDATE()` in 11 files, `DATE()` in
15, plus `CONCAT`, `GROUP_CONCAT`, `DATE_FORMAT`, `LAST_DAY`, `JSON_OBJECT`), so under sqlite most
controllers failed for dialect reasons that said nothing about whether the code was correct — which
is why nothing above unit level could be tested at all. Two details of `phpunit.xml` are load-bearing:

- Its `<env>` entries do **not** override variables already present in the environment. That's
  deliberate — the same file serves local runs (`DB_HOST=db`, the compose service) and CI
  (`DB_HOST=mysql`) without being edited.
- `APP_ENV` must end up as `testing`, or CSRF verification stays on and every POST test fails with
  419. The `<env>` entry alone isn't enough when something else already sets `APP_ENV` (compose does,
  on the app service), so both runners pass it as a real environment variable.

**Building the database:** `php artisan test:prepare-db`
([PrepareTestDatabase](laravel/app/Console/Commands/PrepareTestDatabase.php)) drops `axioma_test`
and replays every `docker/mysql/init/*.sql` in order — the same files that build a dev stack and
bootstrap a branded instance, `999_seed.sql` included. Reusing them instead of maintaining a
separate test schema is the point: they're updated with every schema change, and the seed is already
a coherent fixture set (one user per role with password `password123`, a teacher, a student, a
group, tariffs, a schedule, lessons, payments, charges). Since the command DROPs a database, it
refuses to run unless the name ends in `_test` and the environment isn't production.

**Running:** [scripts/run_tests.sh](scripts/run_tests.sh) rebuilds the test DB and runs the suite
inside the app container (`--no-rebuild` to skip the rebuild, any other argument is passed through
to PHPUnit, e.g. `--filter AuthTest`). It connects as root, read from the repo-root `.env`: the
app's own MySQL user only has rights on the real `axioma` database, so it can't create the
throwaway one. CI does the same steps by hand, plus `docker-php-ext-install pdo_mysql` — the bare
`php:8.4-cli` image has no `pdo_mysql`, unlike the app image.

[laravel/tests/TestCase.php](laravel/tests/TestCase.php) uses `DatabaseTransactions`, not
`RefreshDatabase`: `RefreshDatabase` expects Laravel migrations, and this app's schema lives in
hand-applied SQL (see "Database" above). Each test rolls back afterwards, so the seeded fixtures
stay pristine without being rebuilt per test, and controllers that open their own
`DB::transaction()` nest safely via savepoints. **It only rolls back the database** — anything a
test leaves in the cache (the login throttle's counters, for one) has to be cleared by the test
itself.

Coverage: `Unit/Services` (billing/tariff arithmetic), `Unit/Support`, `Unit/Logging`, and
`tests/Feature`, which now exercises controllers, routes and role guards end to end — `AuthTest`
(both credential shapes, per-role landing pages, the failed-attempt throttle), `RoleGuardTest`
(admin-only pages, and the soft-redirect-not-403 style this app uses), `GroupArchiveTest`,
`PaymentAuditTest`, `StudentDeleteTest`, `ExampleTest`. Each of those files opens with why it
exists; several pin behaviour that a plausible future "fix" would undo, so read the header before
changing what one asserts. `Unit/Support/WeekdaysTest.php` was ported from the old framework's test
suite when it was decommissioned; that suite's `ValidatorTest` had nothing to port to, since
Laravel's own validator replaced the custom one it tested — that coverage was dropped, not
silently, see the decommission commit. Validation behaviour is now reachable the other way round,
through a feature test asserting what a controller does with a bad request.

When adding non-trivial logic, prefer adding a test over relying on manual verification. Logic that
can live in a pure, dependency-free method (see `App\Support\ActivityLog::diff()`) still should —
not because it's the only thing reachable any more, but because a unit test needs no database and
stays fast. Behaviour that only exists as a controller plus a schema now has a home too: put it in
`tests/Feature` rather than deferring it to a manual pass against the Docker stack.

## Timezone

Business operates in `Asia/Almaty` (UTC+5). Set via `laravel/config/app.php`'s `timezone` key —
don't call `date_default_timezone_set()` elsewhere.

Scheduled jobs (daily billing, payment reminders) run via Laravel's scheduler
([laravel/bootstrap/app.php](laravel/bootstrap/app.php)'s `->withSchedule()`), fired by a
production crontab entry invoking `php artisan schedule:run` every minute inside the app
container.

## Related repo

The `bot` service in [docker-compose.yml](docker-compose.yml) builds from a sibling checkout of
`teacher_reminder_bot` (path configurable via `BOT_BUILD_CONTEXT`) — not part of this repo.
