# Bilal Bin Rabah — Backend (Laravel API) Engineering Rules

Authoritative rules for the **مركز بلال بن رباح لتحفيظ القرآن الكريم** backend: a headless
Laravel REST API consumed by the Next.js frontend (repo `bilal-frontend`). **Every backend change
— new endpoint, migration, model, or frontend wiring — MUST follow the conventions below** so the
API stays consistent, secure, and a drop-in match for the frontend's data layer.

- **Stack:** Laravel 12 · PHP 8.2 · MySQL 8 (InnoDB, utf8mb4) · Sanctum (token auth) ·
  spatie/laravel-permission (roles/permissions) · Pest/PHPUnit · Pint (formatting).
- **Repo:** standalone (`bilal-backend`), sibling of the frontend. Deployed independently.
- **API base:** all endpoints under `/api/v1` (the frontend calls `{NEXT_PUBLIC_API_BASE_URL}/api/{version}`).
- **Language:** Arabic-only content for now; designed to extend to multi-language later (see §8).

---

## 1. Engineering principles (non-negotiable)
- **Clean, layered architecture.** SOLID, DRY, KISS, YAGNI. Thin controllers; business logic in
  Actions/Services; persistence in Models. No logic in routes; no queries in controllers beyond
  trivial lookups.
- **Config-driven — no magic values.** No hardcoded strings/numbers/credentials. Config → `config/*`
  + `.env` (read via `config()`, **never** `env()` outside config files). User-facing text → `lang/`.
- **Every layer independently testable.** Feature test per endpoint; unit test pure logic.
- **States are first-class.** Every endpoint defines success, validation-error (422), not-found (404),
  unauthenticated (401), and forbidden (403) behaviour explicitly.
- **Verify before "done".** `php artisan test`, `./vendor/bin/pint --test`, and a fresh
  `php artisan migrate:fresh --seed` must all be green. Never claim done without proof.
- **Root-cause, not band-aid.** No temporary fixes; no scope creep beyond the task.

---

## 2. Architecture & layering
```
app/
  Http/
    Controllers/Api/V1/   Thin controllers — one resource each, RESTful actions only
    Requests/             FormRequest validation (one per write action)
    Resources/            API Resources/Collections — the ONLY place that shapes JSON output
    Middleware/
  Models/                 Eloquent models — relationships, casts, scopes, accessors
  Actions/  (or Services/) Business operations that span >1 model or have real logic
  Policies/               Authorization per model
database/
  migrations/             Schema — one concern per migration, always reversible
  factories/              Test/seed data factories
  seeders/                Seeders (mirror the frontend `lib/dummy/*` for parity)
routes/api.php            v1 route groups only
config/                   Typed config; env read here exclusively
lang/ar/                  Arabic validation + domain messages
tests/Feature/ , Unit/    Pest/PHPUnit
```
**Dependency rule:** `routes` → `Controller` → (`FormRequest` for input, `Action/Service` for logic,
`Model` for data) → response via `Resource`. Controllers never build raw response arrays — always a Resource.

---

## 3. API conventions (must match the frontend contract)
- **Versioning:** everything under `Route::prefix('v1')` in `routes/api.php`. Breaking change → new `v2` group, never mutate v1.
- **Response envelope:** use API **Resources**. Single → `{ "data": { ... } }`; list →
  paginated `{ "data": [...], "links": {...}, "meta": {...} }` (Laravel's default for
  `Resource::collection($paginator)`). The frontend's HTTP-client seam unwraps `data`.
- **camelCase on the wire, snake_case in the DB.** The frontend types are camelCase (`nameEn`,
  `isOrphan`, `levelId`, `studentCode`). Therefore:
  - **DB columns are snake_case** (`name_en`, `is_orphan`, `level_id`) — idiomatic, FK conventions hold.
  - **API Resources output camelCase** (map snake → camel explicitly in `toArray()`).
  - **FormRequests accept camelCase** and map to snake_case before persisting (a shared
    `prepareForValidation()`/normaliser, or explicit mapping in the Action). This boundary mapping is
    mandatory — the frontend must not change its field names.
- **IDs are integers.** Auto-increment `BIGINT` primary keys (frontend types are `id: number`). **No UUIDs** on
  public resources.
- **REST verbs:** `GET` list/show, `POST` create, `PUT` update (treat as partial — the frontend sends
  partial bodies via PUT), `DELETE` remove. Match the resource paths the frontend uses (`/students`,
  `/employees`, `/levels`, `/sections`, `/buses`, `/nationalities`, …).
- **Status codes:** 200 ok, 201 created, 204 no-content (delete), 401/403/404/422 for the error states.
- **Error shape:** rely on Laravel's default JSON errors (`{ "message": ..., "errors": {...} }` for 422);
  the frontend's `ApiError` reads `message`. Don't invent a different shape.
- **List query params:** `?search=` (free-text over documented fields), `?page=`, `?per_page=`, and
  explicit filter params per resource (e.g. `?level_id=`, `?gender=`). Sorting via `?sort=` /`?dir=`.
  Whitelist sortable/filterable fields — never pass user input straight into queries.
- **Pagination default:** sensible `per_page` default (e.g. 25) capped at a max (e.g. 100).

---

## 4. How to add a NEW RESOURCE (follow exactly)
1. **Migration** — `php artisan make:migration create_<table>_table`; snake_case columns matching the
   frontend type; FKs with `foreignId()->constrained()`; indexes on filtered/sorted columns;
   `softDeletes()` where the entity is "deletable" (see §5); `timestamps()`.
2. **Model** — `make:model`; set `$fillable` (or guarded), `casts()`, relationships, query scopes.
   No business logic beyond data concerns.
3. **Factory + Seeder** — mirror the frontend `lib/dummy/<entity>.ts` so mock and real data match.
4. **FormRequest(s)** — `make:request` for store/update; rules + Arabic messages; `authorize()` via Policy;
   normalise camelCase → snake_case input here.
5. **API Resource** — `make:resource`; map snake_case → camelCase output; shape EXACTLY to the frontend type.
6. **Policy** — `make:policy`; gate by role/permission (Spatie). Register implicitly or in the controller.
7. **Controller** — `make:controller Api/V1/<Name>Controller --api`; thin actions; inject FormRequest;
   return Resource/Collection; `$this->authorize()`.
8. **Route** — `Route::apiResource('<plural>', <Name>Controller::class)` inside the v1 group with the
   right auth middleware.
9. **Feature tests** — cover list/show/create/update/delete + auth + validation (422) + not-found.
10. Run `php artisan test`, `pint --test`, `migrate:fresh --seed` — green before done.

---

## 5. Database & migrations
- **Every schema change is ALSO written as raw SQL (MySQL 8) in `database/changes.sql`** — appended at the end in
  run order, with a comment block (date, migration file, why), plus any backfill/permission INSERTs and a pre-check
  SELECT before anything destructive — and logged in the root `CLAUDE.md` §19 schema change log.
- **One concern per migration**; always reversible (`down()` mirrors `up()`); never edit a shipped
  migration — add a new one.
- **Naming:** tables plural snake_case; columns snake_case; FKs `<singular>_id`; pivots alphabetical
  singular (`role_user`).
- **Integrity:** declare foreign keys with `constrained()` + explicit `onDelete` behaviour; add `index()`
  / `unique()` deliberately; default charset utf8mb4 (InnoDB is forced in `config/database.php`).
- **Soft deletes vs archive:** the frontend distinguishes **archive** (a `status`/`archived` flag the user
  toggles — model it as a real column) from **delete** (the trash action — use `SoftDeletes` so records are
  recoverable, not lost). Don't conflate them.
- **Status/enums:** store as short strings (or DB enum) matching the frontend literals exactly
  (`'present'|'absent'|'remote'|'excused'`, `'pending'|'active'|'archived'`, roles, transport types).
- **Seeders mirror the frontend dummy data** so the live API reproduces the demo dataset the UI was built against.
- **Money/dates:** money as integer minor units or `decimal`; dates as `date`/`datetime`; keep any Hijri
  string fields the frontend sends as plain strings.

---

## 6. Models, validation, business logic
- Models hold relationships, `casts`, scopes, accessors/mutators — **not** request handling or response shaping.
- All write input validated through a **FormRequest** (never `$request->all()` into `create()`); keep `$fillable` tight.
- Multi-model / non-trivial operations live in an **Action/Service** class, called by the controller — keeps
  controllers thin and logic unit-testable (e.g. "approve pending student → create student + assign level/section/bus + issue parent password").
- Use DB **transactions** for multi-step writes.

---

## 7. Authentication & authorization
- **Sanctum** personal-access tokens. Login issues a token; clients send `Authorization: Bearer <token>`.
  Protect routes with `auth:sanctum`.
- **Roles/permissions via Spatie.** Frontend roles: `supervisor`, `teacher`, `employee`, `parent`.
  Employees may hold **multiple** roles (e.g. teacher + bus supervisor) — assign multiple Spatie roles.
- **Policies** gate every resource; controllers call `$this->authorize(...)`. Never rely on the frontend
  hiding a button for security.
- Students/parents authenticate with the **auto-generated password** the frontend issues on approval; hash on store.
- Rate-limit auth + public endpoints (`throttle`). Never log tokens/passwords.

---

## 8. Internationalisation (Arabic-only now, extensible)
- Content is **Arabic-only** today: plain columns (`name`, `notes`, …), Arabic validation/domain messages in
  `lang/ar/`. Set `APP_LOCALE=ar`.
- **Design for the future, don't build it yet:** keep user-facing text columns isolated and neutrally named so a
  translations layer (JSON columns or `*_translations` tables / spatie-translatable) can be added later via a clean
  migration — without reshaping the API. Don't add per-language complexity now (YAGNI).

---

## 9. Testing & verification
- **Pest/PHPUnit feature tests per endpoint** (happy path + each error state) and unit tests for Actions/Services.
- Use `RefreshDatabase`; assert on JSON structure (camelCase keys) and status codes.
- Before every delivery: `php artisan test` (green) · `./vendor/bin/pint --test` (clean) ·
  `php artisan migrate:fresh --seed` (no errors). Consider Larastan for static analysis.
- **Never run a full/unfiltered `php artisan test`, `./vendor/bin/pint --test` (repo-wide), or
  `php artisan migrate:fresh --seed` without asking the user to confirm first, every time** — not just once
  per session. Targeted checks scoped to what you touched (`php artisan test --filter=<Test>`,
  `./vendor/bin/pint --test <changed files>`) are fine to run on your own. This applies to subagents you
  dispatch too: tell them to run only scoped checks unless the user has explicitly signed off on a
  full-suite run for that dispatch.

---

## 10. Absolute rules (never violate)
- ❌ No `env()` outside `config/`. ❌ No hardcoded secrets/credentials (use `.env`, keep `.env.example` updated).
- ❌ No `$request->all()` mass-assignment. ❌ No business logic in controllers/routes/models.
- ❌ No raw response arrays — always an API Resource. ❌ No editing shipped migrations.
- ❌ No unauthorized endpoint (every write gated by a Policy/permission). ❌ No N+1 (eager-load relations).
- ❌ No changing the frontend's field names/paths to suit the backend — the API adapts (camelCase boundary).
- ❌ No skipped error states. ❌ No "quick and dirty" — resolve the root cause.

---

## 11. Frontend integration
- The frontend flips from mock → live by setting `NEXT_PUBLIC_USE_MOCK_DATA=false` and
  `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`. The API must then satisfy the exact contract its
  `createResourceService` expects: `GET /<resource>` (list), `GET /<resource>/{id}`, `POST /<resource>`,
  `PUT /<resource>/{id}`, `DELETE /<resource>/{id}`.
- Source of truth for each entity's shape is the frontend TypeScript type in `bilal-frontend/src/lib/types/*`.
  Match field names (camelCase) and value literals exactly.
- CORS is configured for `FRONTEND_URL` in `config/cors.php`. The list/envelope change agreed with the
  frontend (`{ data, meta }` + server pagination) requires the frontend HTTP-client seam to unwrap `data`;
  coordinate any contract change on both sides.

---

## 12. Pre-delivery checklist
- [ ] Migration reversible, snake_case, FKs + indexes, soft-deletes where appropriate
- [ ] Model casts/relationships/scopes; tight `$fillable`
- [ ] FormRequest validation (Arabic messages) + camelCase→snake_case mapping
- [ ] API Resource outputs camelCase matching the frontend type exactly
- [ ] Policy/permission gates the endpoint; auth middleware applied
- [ ] Factory + seeder mirror the frontend dummy data
- [ ] Feature tests cover success + 401/403/404/422; `php artisan test` green
- [ ] `pint --test` clean; `migrate:fresh --seed` clean
- [ ] No `env()` outside config; no hardcoded values; `.env.example` updated
- [ ] Every action (index/show/create/update/delete/bulk) gated by a permission + Policy (§13)

---

## 13. Authorization is per-action — ALWAYS (RBAC)
**Every endpoint maps to a permission and is gated by a Policy.** For each resource, the
`index/show/create/update/delete` (and `bulk-delete`) actions check a `<domain>.<action>` permission
(`students.viewAny`, `students.create`, `students.update`, `students.delete`, …) via `$this->authorize()`.
There is **no unprotected write**, and list/show require the matching `view`/`viewAny` permission. This
mirrors the frontend, which **hides** the corresponding page/buttons when the permission is absent — the
backend `403` is the real boundary. When you add a resource you MUST add its permissions to the
seeder/catalog and write a Policy. Permission catalog: `docs/frontend-integration-roadmap.md` §5.

**Roles model (Spatie):** `super-user`, `supervisor`, `teacher`, `employee`, `parent` (+ `student` reserved).
- **`super-user` sees and can do EVERYTHING** — it bypasses permission checks **and** the frontend
  hidden-features mask. Implement via `Gate::before(fn ($u) => $u->hasRole('super-user') ? true : null)`.
  It is **seeded exactly once**, **never assignable via the API/UI**, and **omitted** from any role list
  returned for user/employee creation. Reject any request attempting to grant it.
- **Multi-role = UNION.** A user holding several roles gets the **union** of all their roles' permissions in
  one session — **no role switching, no `X-Session-Role`**. Spatie's `getAllPermissions()` already unions.
  Data scoping uses the **most permissive** held role (supervisor → unrestricted; else teacher → own circles;
  parent → own children).

**Self-service profile (every authenticated user):** `GET /api/v1/auth/me`, `PATCH /api/v1/auth/profile`
(self-editable: name, single `phone`, email, photo — **never** roles/branch/status/permissions/linkage; the
Employee entity's `phone`+`altPhone` are edited on the employees screen, not here),
`POST /api/v1/auth/password` (requires current password). Authorize as owner-only; profile edits must never
change roles or permissions.

**Runtime role↔permission management (super-admin page):** the role→permission grants are **editable at
runtime by the super-user only** via `GET /api/v1/roles` (roles + their permissions), `GET /api/v1/permissions`
(the full catalog, grouped by domain), and `PUT /api/v1/roles/{role}/permissions` (sync). The seeder provides
the defaults; the page lets the super-admin adjust them. The permission **catalog** (the universe of
`<domain>.<action>` strings) stays code-defined/seeded; only the **grants** are editable. The `super-user`
role itself is never editable/assignable. After any change call
`app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions()`; gate the whole surface with a
`roles.managePermissions` permission held only by super-user.

---

## 14. Performance & caching (ALWAYS)
- **No N+1.** Eager-load every relation an endpoint serializes (`with(...)`); `Model::shouldBeStrict()` is on
  in non-prod to surface lazy loads. Select only needed columns; index every filtered/sorted/FK column.
- **One request per screen.** If a page needs several models, return them in **one** endpoint, not many
  round-trips. Provide **composite "screen/bootstrap" endpoints** that bundle a page's lookups + data
  (e.g. the student form's nationalities/emirates/regions/levels/sections/buses; the pending-approval
  level/section/bus sets; the memorization session's circle/students/sessions). Prefer one well-shaped
  response (or `?include=`) over the FE firing N parallel calls.
- **Cache rarely-changing data.** Public CMS content (homepage/landing, news, album, library, site-settings)
  and lookup tables (nationalities, emirates, regions, buses, sections, employees, levels, …) are **cached**
  with `Cache::remember`. **No Redis** on this server — the cache/queue stores are **`database`**, which does
  **NOT** support cache tags, so keys are **explicit and namespaced** (`content:home`, `lookups:nationalities`)
  and forgotten directly — never `Cache::tags(...)`. Never cache per-user/role-scoped or frequently-changing
  lists.
- **Cache invalidation is CENTRAL, never per-controller.** `App\Support\CacheInvalidation` maps
  **model class → every cache key that model's writes make stale**, and
  `App\Observers\CacheInvalidationObserver` (registered in `AppServiceProvider`, exactly like the audit
  observer) applies it on every Eloquent write. **Controllers do NOT bust their own key.** The reason is that
  a cached payload is a denormalized SNAPSHOT of several entities: the buses lookup carries its driver's
  `driverName`/`driverPhone`, the sections lookup carries its level's `levelName`, its staff names and a live
  `studentCount`, `/public/home` carries a student count. "The owning controller forgets its own key" left
  `/drivers` editing a phone that the transport report kept serving from `lookups:buses:v2` for a day, and
  left `/teachers` and `/drivers` never busting the employees lookup at all.
  - **Adding a cached payload:** add its key to `CacheInvalidation::MODEL_KEYS` under *every* model whose data
    the payload embeds (and to `LookupCache::ALL` if it is a lookup) — that is the one edit.
  - **The only invalidation a caller writes** is for the writes Eloquent cannot see, the same split
    `AuditObserver` documents: a mass `whereIn(...)->delete()` (`bulkDelete`) and a **pivot sync** — and a
    pivot's forget must come *after* the pivot write (`SyncStudentPlacementsAction`, `BusController`'s region
    sync), never before, or a concurrent read repopulates the stale entry.
  - Cache **keys** live in `LookupCache` / `PublicContentCache` / `CacheInvalidation` (the CMS list keys) and
    are referenced from the controllers — never re-declared as a second string literal.
- **Paginate** every list (default 25, cap 100); never return unbounded sets except via the deliberate
  `listAll()`/report path. Use DB transactions for multi-step writes and queue slow side-effects.

---

## 15. Multi-assignment model — READ before touching any roster query or section scope
1. **`section_student` (student ↔ sections, `is_primary`)** is the source of truth for section membership. A
   student may be placed in several sections; `students.level_id`/`section_id` are the **PRIMARY** placement
   only (denormalized, for the single-value screens). **Never** scope a roster with
   `where('section_id', $x)` on `students` — use `whereHas('sections', fn ($q) => $q->whereIn('sections.id', …))`
   (`Student::sections()` / `Section::students()`). `SyncStudentPlacementsAction` is the ONLY writer: it keeps
   the pivot and the primary columns consistent (called from student store/update/transfer + approve-pending).
2. **`section_staff` (section ↔ employees, `role` = `teacher`|`supervisor`, `position`)** replaces the single
   `sections.primary_teacher_id`/`assistant_teacher_id`, which remain as DERIVED first-of-list pointers
   (written only by `SyncSectionStaffAction`, never from a payload). A section's **supervisor (مشرف) has the
   same abilities over it as its teacher**.
3. **`App\Support\SectionAccess` is the ONE section-scoping boundary.** `isUnrestricted()` = holds an oversight
   role (`supervisor`/`coordinator`/`super-user`); everyone else is scoped to `sectionIdsFor()` — every section
   they are staff on, either role. Every education controller/policy delegates here (`ownSectionIds()`,
   `canAccessSection()`); do NOT re-derive the set, and do NOT key scoping on `hasRole('teacher')`.
   `Employee::sections()` is a `belongsToMany` over the pivot and can yield a section twice (both roles), so
   dedupe before exposing ids/names.
4. **Records are PER SECTION**: `attendance_records` and `memorization_sessions` are unique on
   `(student_id, section_id, date)`; `sub_subject_grades` on `(student_id, section_id, subject_id)`. Upserts
   must include `section_id` in the key, and any per-student aggregate (e.g. the attendance range summary)
   must accept a section filter or it will sum a student's several sections into one row.
5. **Cached lookup shape**: the shared sections lookup lives in `App\Support\LookupCache` —
   `LookupCache::SECTIONS` (the versioned key), `sectionQuery()` (relations + headcount) and `sections()` (the
   cached full list). `SectionController` and `StudentFormLookupController` both read through it; they used to
   define their own key + eager-loads, drifted (the form lookup omitted `teachers`/`supervisors`), and whichever
   controller warmed the entry first decided whether every section looked staffed for the next day — which is how
   the levels print sheet printed an empty table. Never re-declare either: build on `sectionQuery()`, and bump the
   key's version suffix whenever the cached payload SHAPE changes or a pre-deploy entry is served for up to a day.

**North star:** the API should feel built by a senior Laravel team — predictable, secure, fully tested, and a
seamless match for the frontend. Engineers inheriting it should feel relief, not dread.
