# WODI Childcare — Daycare Portal

Two halves in one repo, talking over JSON:

| Half         | Path        | Stack                                                                     |
| ------------ | ----------- | ------------------------------------------------------------------------- |
| **API**      | repo root   | Laravel 13, PHP 8.3, Sanctum, Pest 5                                      |
| **SPA**      | `frontend/` | React 19, Vite, TypeScript, React Router, TanStack Query + Store, Tailwind 4 |

Laravel serves **no HTML except the SPA shell**. `routes/web.php` returns `resources/views/app.blade.php` for every non-`/api` path; React Router owns routing from there. `frontend` builds into `public/build` and Laravel reads the Vite manifest. In dev, Vite proxies `/api` → `localhost:8000`, so everything is same-origin and there is no CORS layer.

**`docs/db-design.md` is the authoritative blueprint** — 56 migrations across nine domain phases, with the table specs, ERDs, and the decisions behind them. Read the relevant phase before touching a domain. It is the source of truth for schema; this file is the source of truth for code shape.

## State of the codebase

The schema and 46 models exist. **Almost nothing above them does** — one abstract `Controller`, zero requests, zero actions, zero resources, zero policies, and only the stub Pest tests. So the conventions below are prescriptive, not descriptive: you are laying the first tracks, and the first few endpoints set the pattern everything else copies.

**The conventions here are ported from `/Users/logic/artisan/pharmacy/backend`, which is the same architecture running in production.** When something below is ambiguous, read the equivalent file there rather than inventing a variant.

None of the shared plumbing exists yet. Build it before (or with) the first endpoint, in this order:

1. `app/Support/RespondsWithHttpStatus.php` — the `success` / `failure` / `validateResp` trait
2. `app/Support/Utils.php` — static `success` / `failure` / `error`, plus file helpers (`uploadTemporary`, `moveFiles`, `deleteFile`, `filePath`)
3. `app/Http/Controllers/Controller.php` — `use RespondsWithHttpStatus` + the `resource()` helper
4. `app/Http/Resources/BaseResource.php` — the single envelope + media resolver
5. `bootstrap/app.php` — `RespondWithJson` middleware on the `api` group, guard aliases, and the full `$exceptions->respond()` map
6. `routes/staff.php` + the mount in `routes/api.php`

Copy 1–5 from the pharmacy backend nearly verbatim; only the guard names and route mounts differ.

## Commands

```bash
composer dev              # serve + queue:listen + pail + frontend vite (concurrently)
composer test             # config:clear + artisan test
php artisan test --filter=StudentTest
vendor/bin/pint           # PHP formatting (no pint.json — Laravel preset)

npm run frontend:dev      # vite on :5173, proxying /api to :8000
npm run frontend:build    # tsc -b && vite build → public/build
cd frontend && npm run lint && npm run format
```

# Frontend Conventions
- **Composition over config** for forms & filters — `FilterPanel` + `FilterSelect`/`FilterInput` children (and dedicated field components), never a schema/field-generator like the legacy `GlobalForm`.
- **All select dropdowns use `SearchableSelect`** (`components/SearchableSelect.tsx`) — never a native `<select>`.
- **Shared helpers** live in `support/utils.ts` (`cn`, `formatNaira`, `capitalize`, `initials`, `cleanParams`, `makeUrl`, `galleryUrl`, date helpers…). Pure functions never go in `hooks/` and never carry a `use` prefix.
- **Routes:** flat string map in `routes/index.ts`, wired in `routes/RouteList.tsx`.
- **Components** live flat in `components/` (no `ui/` subfolder) when shared across features.
- **Feature folders:** pages live under `pages/<feature>/`; components used only by one feature go in that feature's `partials/` subfolder (e.g. `pages/customers/partials/`). Promote to top-level `components/` only once it's reused elsewhere.
- **Comments:** minimal and non-restating.


---

# Backend: Laravel API

## The write path: FormRequest → Action

**Every request that modifies a resource goes through a FormRequest and an Action class. No exceptions.** Validation and authorization live in the request; the state change lives in the action; the controller only wires them together and shapes the response.

```
POST /api/… → FormRequest (authorize + rules) → Controller (2–3 lines) → Action (the mutation) → JSON
```

### 1. Action classes

Actions live in `app/Actions/{Domain}/`, where `{Domain}` is one of the nine phases mapped below. There are **two shapes**, and the weight of the business logic picks which:

**a) One CRUD action per resource — the default.** Plain create/update/delete gets a single class with one `handle()` that dispatches on the operation with a `match`. Most of the 46 models need only this.

```php
<?php

namespace App\Actions\Backbone;

use App\Models\Classroom;
use InvalidArgumentException;

class ClassroomAction
{
    public function handle(string $action, array $payload = [], ?Classroom $classroom = null): ?Classroom
    {
        return match ($action) {
            'create' => $this->create($payload),
            'update' => $this->update($classroom, $payload),
            'delete' => $this->delete($classroom),
            default => throw new InvalidArgumentException("Unsupported classroom action: {$action}"),
        };
    }

    private function create(array $payload): Classroom
    {
        return Classroom::create($payload);
    }

    private function update(?Classroom $classroom, array $payload): Classroom
    {
        $this->ensure($classroom);

        $classroom->update($payload);

        return $classroom->fresh();
    }

    private function delete(?Classroom $classroom): null
    {
        $this->ensure($classroom);

        $classroom->delete();

        return null;
    }

    private function ensure(?Classroom $classroom): void
    {
        if (! $classroom) {
            throw new InvalidArgumentException('Classroom is required for this action.');
        }
    }
}
```

The shape is fixed: `handle(string $action, array $payload = [], ?Model $model = null)`, a `match` with a `default` that throws `InvalidArgumentException`, and one private method per arm. The nullable model plus an `ensure()` guard is what lets a single signature serve create (no model) and update/delete (model required).

**List and stats queries get their own action** when they carry real filtering — `{Noun}ListAction`, `{Noun}StatsAction`, one `handle(array $filters)` returning a paginator, `when()` chains for each filter and a private `match` helper for status narrowing. A plain unfiltered index stays inline in the controller; don't create a `ListAction` to wrap `->paginate()`.

**b) A dedicated action per change — only when the logic is genuinely large.** Split when a single operation carries real domain weight: multi-table writes, money math, state machines, cascading side effects. Not because a method got to fifteen lines.

Real examples in this schema: issuing an invoice (resolve fee schedules + subsidies + splits across payers, write lines), allocating a payment across invoices via `payment_invoice`, enrolling a student (close the open enrollment, open the next, touch billing), signing off an incident. Everything else — classrooms, grades, holidays, expense categories, emergency contacts, training records — is CRUD, and dedicated actions for those are churn.

```php
<?php

namespace App\Actions\Billing;

use App\Models\Invoice;
use App\Models\Payer;
use Illuminate\Support\Facades\DB;

class IssueInvoiceAction
{
    /**
     * @param  array<string, mixed>  $data
     */
    public function handle(Payer $payer, array $data): Invoice
    {
        return DB::transaction(function () use ($payer, $data) {
            $invoice = $payer->invoices()->create([
                'school_id' => $payer->school_id,
                'issued_on' => $data['issued_on'],
                'due_on' => $data['due_on'],
                'status' => 'open',
            ]);

            foreach ($data['lines'] as $line) {
                $invoice->lines()->create($line);
            }

            return $invoice->refresh()->load('lines');
        });
    }
}
```

Rules:

- **Naming follows the shape.** CRUD actions are `{Noun}Action` — `ClassroomAction`, `HolidayAction`, `ExpenseCategoryAction`. Split actions are `{Verb}{Noun}Action` — `EnrollStudentAction`, `IssueInvoiceAction`, `AllocatePaymentAction`, `SignOffIncidentAction`. Query actions are `{Noun}ListAction` / `{Noun}StatsAction`. Every action class ends in `Action`; never `StudentService`.
- **One public method, `handle()`.** Private helpers are fine; a second public entry point means it's a second action.
- **Splitting is one-way and total.** When a resource outgrows CRUD, move *all* its operations out of the `match` into dedicated actions and delete the CRUD class — never leave create in a `match` and update in its own class.
- **Takes models and a plain array, never HTTP.** No `Request`/`FormRequest` type-hints, no `request()`, `auth()`, `abort()`, or `response()` inside an action. If the action needs the actor, accept `User $actor` as an argument.
- **Returns the thing it changed** — the fresh model, or `null` for deletes (hence the `?Model` return type). Let the controller decide what to `load()`.
- **Owns the whole change** — related rows, pivots, media, notifications, and `DB::transaction()` whenever more than one table is written. Money math (splits, subsidies, allocations) rounds to the cent inside the action.
- **Resolved from the container.** Type-hint it as a controller-method parameter; never `new` it in a controller.
- Reusable non-domain machinery (file storage, PDF rendering, third-party clients) goes in `app/Services` and is constructor-injected into actions.

### The domain map — every table has a home

`{Domain}` is the same segment for actions, requests, resources, controllers, policies, and tests. All 53 domain tables, no leftovers:

| `{Domain}`      | Phase | Models                                                                                  | Pivots / tables with no model                                            |
| --------------- | ----- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `Backbone`      | 1     | School, Grade, Classroom, Family, Student, Guardian, Payer, Enrollment                    | `classroom_teacher`, `student_guardian`, `student_payer`, `guardian_password_reset_tokens` |
| `Attendance`    | 2     | Attendance, Holiday                                                                       | —                                                                        |
| `Billing`       | 3     | FeeSchedule, FeeItem, StudentFee, Subsidy, StudentSubsidy, Invoice, InvoiceLine, Payment  | `payment_invoice`                                                        |
| `Care`          | 4     | CareLog, Incident, CareMedia, Post, PostMedia                                             | —                                                                        |
| `Admin`         | 5     | User, Role, Permission, StaffProfile, EmergencyContact, Document                          | `permission_role`, `role_user`                                           |
| `Communication` | 6     | Conversation, ConversationParticipant, Message, MessageAttachment, Announcement, AnnouncementRead | —                                                                 |
| `StaffOps`      | 7     | Shift, TimeEntry, TimeOffRequest, TrainingRecord                                          | —                                                                        |
| `Finance`       | 8     | ExpenseCategory, Expense, TaxReceipt                                                      | —                                                                        |
| `Health`        | 9     | HealthProfile, Allergy, Medication, Immunization                                          | —                                                                        |

Pivots don't get models, actions, or resources — the owning action writes them (`$student->guardians()->attach(...)`, `$invoice->payments()->attach(...)` with pivot data). `Admin` (staff accounts + RBAC + records) and `StaffOps` (scheduling, timekeeping, HR) are separate namespaces; don't merge them.

### Cross-cutting schema facts

Settled in `docs/db-design.md` — don't relitigate or work around these:

- **No shared `media` table.** Files — student photos, care media, post media, message attachments, documents — are **plain path columns on their owning tables**. Never introduce a polymorphic media table. `documents` is the one general file record (polymorphic via `documentable`), for expiring paperwork, not for images.
- **Notifications add no schema.** Reminders ride existing due-date columns (`invoices.due_on`, `immunizations.due_on`, `training_records.expires_on`, `documents.expires_on`) plus a daily scheduled job and Laravel's `notifications` table. A new reminder means a scheduled job and a Notification class — not a new table.
- **No form builder, no e-signatures** — dropped by decision. Structured intake lives in real tables (health, allergies, immunizations, emergency contacts); free-form paperwork lives in `documents`.
- **Statuses are `string` columns** validated in the app. No DB enums anywhere.

### 2. FormRequests

Every store / update / destroy / custom-verb endpoint gets one, in `app/Http/Requests/{Domain}/{Store,Update,…}{Noun}Request.php`. A single `{Noun}Request` is fine when create and update share a ruleset.

```php
<?php

namespace App\Http\Requests\Enrollment;

use App\Models\Classroom;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class EnrollStudentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('enroll', $this->route('student')) ?? false;
    }

    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'classroom_id' => [
                'required',
                Rule::exists('classrooms', 'id')->where('school_id', $this->user()->school_id),
            ],
            'started_on' => ['required', 'date'],
            'ended_on' => ['nullable', 'date', 'after:started_on'],
        ];
    }
}
```

Rules:

- **`authorize()` is for record-level checks only.** Guard and role are already enforced at the route mount (`auth:staff`, `auth.admin`), so `return true` is correct and honest when nothing record-specific is at stake. Return a policy call — `$this->user()?->can('update', $this->route('student')) ?? false` — when the answer depends on *which* record, which for this app means anything a teacher can only touch for their own classroom.
- Rules are **arrays, not pipe strings**. Keys are `snake_case` and match the columns in `docs/db-design.md`.
- **Every `exists` rule on a tenant-owned table is scoped to the actor's `school_id`** (see Tenancy below). An unscoped `exists` is a cross-tenant data leak.
- Fixed vocabularies come from the enums — `Rule::in(InvoiceStatusEnum::values())`, never an inline array of strings and never a DB enum.
- Constants that are request-shaped rather than domain-shaped (a list of allowed morph aliases, say) can live as a `public const` on the request itself.
- File fields validate as `['nullable', 'string', 'max:500']` — the client sends a temp path, not an upload (see Files and media).
- Share repeated rulesets via a trait in `app/Concerns`, not by extending one request from another.
- Normalize input in `prepareForValidation()` (trim, `''` → `null`, cast numeric strings) so actions receive clean data.

### 3. Controllers

Thin, one per resource. `app/Http/Controllers/{Area}/{Noun}Controller.php`, where `{Area}` is `Staff`, `Family`, or `Common` (see Routes below) — the controller's *audience*, not its domain. **Actions are constructor-injected**, not method-injected:

```php
class ClassroomController extends Controller
{
    public function __construct(
        private readonly ClassroomAction $classroomAction,
    ) {}

    public function index(Request $request): JsonResponse
    {
        $classrooms = Classroom::query()
            ->with('grade')
            ->when($request->filled('search'), fn ($q) => $q->search($request->search))
            ->latest()
            ->paginate($request->integer('per_page', 20))
            ->withQueryString();

        return $this->success($classrooms);
    }

    public function store(StoreClassroomRequest $request): JsonResponse
    {
        $classroom = $this->classroomAction->handle('create', $request->validated());

        return $this->success($classroom->load('grade'), 'Classroom created successfully.', 201);
    }

    public function update(UpdateClassroomRequest $request, Classroom $classroom): JsonResponse
    {
        $classroom = $this->classroomAction->handle('update', $request->validated(), $classroom);

        return $this->success($classroom->load('grade'), 'Classroom updated successfully.');
    }

    public function destroy(Classroom $classroom): JsonResponse
    {
        $this->classroomAction->handle('delete', classroom: $classroom);

        return $this->success(null, 'Classroom deleted successfully.');
    }
}
```

- **Always `JsonResponse` as the return type**, always through `$this->success(...)` / `$this->resource(...)`. Never a bare `response()->json()`, never a raw model.
- Pass `$request->validated()` into the action — never `$request->all()`. (`ListAction`s are the exception: they take `$request->all()` as a filter bag.)
- Use the named-argument form when skipping the payload: `handle('delete', classroom: $classroom)`.
- `->load(...)` the relations the client needs *after* the action returns.
- Simple index queries stay here; heavy filtered ones move to a `{Noun}ListAction` and the controller becomes `return $this->success($this->listAction->handle($request->all()));`.
- No business logic, no `$request` past the first line of a write method.

### 4. Response envelope

**Every API response has the same three-key shape** — this is the contract the SPA is built on:

```jsonc
{ "success": true,  "message": "Classroom created successfully.", "data": { … } }   // 2xx
{ "success": false, "message": "This action is unauthorized." }                     // 4xx/5xx
{ "success": false, "message": "The email field is required.", "errors": { … } }     // 422
```

It comes from a `RespondsWithHttpStatus` trait in `app/Support`, used by the abstract `Controller`, plus static twins on `App\Support\Utils` for use outside controllers (the exception handler):

- `$this->success($data = [], $message = 'successful', $status = 200)`
- `$this->failure($message = '…', $status = 409)`
- `$this->validateResp($errors)` — throws a `ValidationException`, so it renders as a normal 422

**One `BaseResource`, never per-model resources.** `app/Http/Resources` holds exactly one class: `BaseResource extends JsonResource`, which emits the same `{ success, message, data }` envelope and resolves stored file paths into URLs. Reach for it via `$this->resource($data, $message, $status)` on the base controller **only when the payload has file columns**; otherwise `$this->success()` is the default.

```php
return $this->resource($students, 'Students');                       // paginator, media resolved
return $this->resource($student->load('guardians'), 'Student', 201); // single model
```

- It handles model, Eloquent collection, and paginator — and for paginators it preserves the pagination metadata, so the client still reads `data.data`, `data.current_page`, `data.total`.
- It recurses into eager-loaded relations, so nested models get their URLs too.
- **Never create `StudentResource`, `InvoiceResource`, or a `ResourceCollection`.** Field selection is `$hidden` / `$visible` / `select()` on the query, not a resource class.

Because there's no per-model whitelist, `$hidden` is the only thing between a column and the wire — audit it on any model that gains a sensitive column (tokens, internal notes, `password`, staff pay figures).

### 5. Files and media

The schema stores **plain path columns**, and URL building is deferred to response time — a model read never pays for it.

- A model with file columns declares them: `public function mediaFields(): array { return ['photo_path']; }`.
- `BaseResource` then adds a sibling `photo_path_url` to the JSON. Array columns of `{ path: … }` entries each gain a `url`.
- **Uploads are two-phase.** The client POSTs to a shared temporary-upload endpoint, gets back `{ path, name, size, url }`, and submits that temp `path` as a normal string field. The action promotes it on save with `Utils::moveFiles($payload['photo_path'], 'students')` and deletes the previous file on swap or delete.
- Which means: **file handling lives in the action, not the controller**, and the FormRequest validates the path as `['nullable', 'string', 'max:500']` — not as a `file`.

### 5b. Room banners

Ported from the preschool portal: 56 hand-authored flat-lay SVGs in `public/images/banners/`, catalogued in `resources/portal/banners.json` (9 categories; each entry `{key, label, category, bg}`).

- **Only the key is ever stored** on `classrooms.banner` — never a colour, never a URL, never a CSS class. Tailwind cannot scan the database, so the cover must be an inline `background-image` on the client. `Rule::in(ClassroomBanner::keys())` enforces it; a URL in that field is a rejected payload, not a stored one.
- `App\Support\ClassroomBanner` is the PHP reader — a plain final class, not Eloquent, caching the manifest per process.
- Each SVG is composed `1200x400` with its objects clustered right and the left third left clear, so a room name laid over the left always lands on flat colour. **That is why the client anchors it `center right`** — centring would slice the objects off. Same crop in the card and in the picker.
- The SPA is a separate build, so the manifest is **served, not duplicated**: `GET /api/staff/banners`, cached with `staleTime: Infinity`. Cards don't need it — `bannerUrl(key)` derives the path from the key alone, and the room's own `color` paints behind while the SVG loads.
- Vite proxies `/images` and `/storage` to Laravel in dev; without that every banner 404s on `:5173` while working in production.

### 6. Field naming

`snake_case` on the wire in both directions — payload keys hit `FormRequest::rules()`, response keys come off the columns. Nothing camelizes at the boundary. Money is cast `decimal:2` (serializes as a fixed-2 string, never `float`); computed fields are accessors in `$appends`.

## Models

Already written for all 56 tables — follow what's there. Classic Eloquent, no narrative comments:

- `protected $fillable` (never `$guarded = []`), `protected $hidden` for secrets, casts through the `protected function casts(): array` method.
- Typed relation methods (`BelongsTo`, `HasMany`, `BelongsToMany`, `HasOne`, `MorphMany`) with `withPivot(...)->withTimestamps()` on pivots that carry data.
- `use SoftDeletes;` on record tables (students, classrooms, guardians, families, payers, invoices…).
- **Money is `decimal(10,2)`** cast to `decimal:2`. Never `float`, never integer `_cents` columns.
- Status columns cast to their backed enum (`'status' => InvoiceStatusEnum::class`); models with file columns declare `mediaFields()`.

## Routes

Split by **audience**, mounted with the prefix and guard applied once at the mount point so no individual route repeats them:

```php
// routes/api.php — public + family-facing
Route::prefix('staff')->middleware('auth:staff')->group(base_path('routes/staff.php'));
```

- Route files are **flat lists of `Route::get/post/put/delete`** — no `Route::apiResource`, no nested `prefix()` groups inside the file. The mount point already carries the prefix and middleware; a comment block at the top of the file says so.
- **Order matters:** literal segments before wildcards. `students/stats` and `students/import` must be declared above `students/{student}`, or the wildcard swallows them.
- Custom verbs are their own routes (`invoices/{invoice}/void`, `students/{student}/enroll`), not extra arguments on a CRUD route.
- `routes/web.php` keeps only the SPA catch-all. Nothing else belongs there.

## Middleware and error handling

Configured in `bootstrap/app.php` — this is what lets every controller assume JSON and skip try/catch:

- **`RespondWithJson` is appended to the `api` group** and force-sets `Accept: application/json` on every request, so a client that forgets the header still gets JSON.
- `shouldRenderJsonWhen(fn () => true)` — no HTML error pages, ever.
- **`$exceptions->respond()` maps every exception type to `Utils::failure($message, $status)`**, so errors carry the same `{ success, message }` envelope as successes. The map covers `ModelNotFoundException` → 404 with the model name interpolated, `NotFoundHttpException` → 404, `AuthorizationException` / `AccessDeniedHttpException` → 403, `AuthenticationException` → 401, `ThrottleRequestsException` → 429, `MethodNotAllowedHttpException` → 405, `PostTooLargeException` → 413, `QueryException` / `RelationNotFoundException` → a generic 500 that leaks nothing, and `ValidationException` → `Utils::error()` → 422 with `errors`.
- **Consequence: don't try/catch in controllers or actions.** Throw — `abort(403)`, `ValidationException::withMessages()`, `InvalidArgumentException` — and let the handler shape it. A `try/catch` that returns `$this->failure()` is only for genuinely expected third-party failures (a payment gateway, an upload).
- Named middleware aliases go in the same file (`auth.staff`, `auth.optional`, `checkToken`) — a one-line guard class in `app/Http/Middleware`.

## Enums

Statuses and fixed vocabularies live in `app/Enums/{Name}Enum.php` as backed string enums — `EnrollmentStatusEnum`, `InvoiceStatusEnum`, `PaymentStatusEnum`, `AttendanceStatusEnum`, `IncidentTypeEnum`. Columns stay `string` in the DB (no DB enums, per `docs/db-design.md`); the enum is the app-side vocabulary.

Reference them everywhere the value appears: `Rule::in(InvoiceStatusEnum::values())` in requests, `'status' => InvoiceStatusEnum::class` in model casts, and the enum case in actions rather than a bare string literal.

## Tenancy

Every domain table carries `school_id`. There is one school today and multi-school is deliberately not built — but **the seam is load-bearing, so never write a query that ignores it.** Scope every read and every `exists` rule to the authenticated user's `school_id`. When you add the second real endpoint, propose a global scope or middleware rather than repeating `->where('school_id', ...)` by hand — that's a decision to surface, not to bake in silently.

## Auth

`config/auth.php` defines two guards over separate tables, so a family login can never touch staff data:

| guard      | table       | who                        | how they get in                             |
| ---------- | ----------- | -------------------------- | ------------------------------------------- |
| `staff`    | `users`     | admin, teachers, finance   | admin-provisioned; login + forgot/reset only |
| `guardian` | `guardians` | families                   | self-service register → verify email → login |

A `student` guard is reserved but not enabled. One login screen with a **Families | Staff** toggle picks the guard; forgot/reset carries the selection so a shared email resets against the right broker.

Staff permissions come from the `roles` / `permissions` / `permission_role` / `role_user` tables, plus `users.is_super`. Put the checks in policies (`app/Policies`) and call them from `FormRequest::authorize()`.

## Migrations

The app is not live. **Edit the existing migration in place and re-run `php artisan migrate:fresh --seed`** — don't stack corrective migrations. If the change alters the design, update `docs/db-design.md` in the same commit.

## Seeders — grow them with the build

Because `migrate:fresh` is the normal workflow, **a wiped database must come back with enough data to resume work exactly where it stopped.** Nobody hand-inserts a record to test a screen.

**Every feature ships its seeder in the same commit as its endpoints.** Build the students directory → `StudentSeeder` lands with it. Build billing → `InvoiceSeeder` lands with it. A feature whose screens can't be opened after `migrate:fresh --seed` is not finished.

- One seeder per domain concept, `database/seeders/{Noun}Seeder.php`, registered in `DatabaseSeeder` **in dependency order** — school → roles/permissions → staff → grades → classrooms → families/guardians → students → enrollments → everything else. `DatabaseSeeder` is the running record of that order; append, never reorder around an existing entry.
- **Seeders are idempotent.** `firstOrCreate` / `updateOrCreate` keyed on something stable (email, slug, invite code), never bare `create()`. Running the seeder twice must not double the data — that's what lets you re-seed without a fresh migrate when you only added one seeder.
- **Deterministic identities, faker for volume.** The accounts you log in as every day are fixed and documented (`admin@wodi.test`, `teacher@wodi.test`, `family@wodi.test`, all password `password`). Bulk rows behind them can be factory/faker-generated.
- **Seed realistic shape, not one row.** A class with one child proves nothing about the roster screen. Enough children to scroll, a few classrooms across grades, some students with two guardians and some with one, invoices in every status the UI branches on.
- Reference-ish data (permissions catalog, grades, holidays) seeds from an array constant in the seeder so the vocabulary lives in code, not in a dump.
- `php artisan db:seed --class=StudentSeeder` must work standalone — a seeder resolves its own dependencies with `firstOrCreate` rather than assuming another seeder just ran.

## Testing

Pest 5, feature tests through the HTTP layer in `tests/Feature/{Domain}/`.

```php
$this->actingAs($admin, 'staff')
    ->postJson("/api/students/{$student->id}/enrollments", [...])
    ->assertCreated()
    ->assertJsonPath('data.classroom_id', $classroom->id);
```

- Hit real routes with literal URLs and `*Json` helpers; don't unit-test an action a route already exercises.
- Assert against the envelope — `assertJsonPath('success', true)` and `data.*` paths. A test asserting a top-level field means the controller skipped `$this->success()`.
- Every mutating endpoint needs: the happy path, a validation failure (`assertJsonValidationErrors`), an unauthenticated `401`, a wrong-role `403`, **and a cross-tenant `404`/`403`**.
- Assert on the persisted model (`assertDatabaseHas`) as well as the response.
- Actors in `beforeEach`, data from factories. Factories don't exist for most models yet — write one when you first need it.

---

# Frontend: React SPA (`frontend/`)

A standalone Vite SPA. **No Inertia, no Blade, no server-rendered props** — every screen gets its data from the API through TanStack Query. Its only integration point with Laravel is the build output and the `/api` prefix.

⚠️ **The scaffolding is a port from a retail app and still says so.** `services/index.ts` sends an `X-Store-Id` header, `support/utils.ts` uses `shop_token` / `shop_store_id` localStorage keys, `authStore` holds an `Operator`, `services/auth.query.ts` prefixes `outlet/v1`, `routes/index.ts` lists `qcommerce`/`pricelist`/`lostSales` routes, and `RequireStore` gates on a selected store. **None of that is the daycare domain.** When you touch one of these, rename it to the real model (school, staff user, family) rather than extending the retail vocabulary. See "Open decisions" below — the auth transport is genuinely unsettled and shouldn't be guessed at.

## Layout and naming

```
frontend/src/
  pages/staff/{feature}/Screen.tsx    # school-side screens
  pages/staff/{feature}/partials/     # used by that feature only
  pages/family/{feature}/Screen.tsx   # family-side screens
  pages/family/{feature}/partials/
  pages/partials/                     # domain components shared across features
  layouts/                            # the two layouts + the shell pieces they compose
  components/                         # generic UI primitives only — flat, PascalCase
  hooks/useThing.ts                   # camelCase, use* prefix
  services/thing.query.ts             # one file per domain — queries + mutations
  stores/thingStore.ts                # TanStack Store, module-level
  types/thing.ts                      # shared domain + payload types
  routes/index.ts                     # the path registry
  support/utils.ts                    # cn(), token helpers, small pure helpers
```

**Three tiers, and the test is *what it knows*, not how often it is used:**

1. **`components/`** — generic UI primitives with no domain in them: `EmptyState`, `InputError`, `LoadingButton`, `PasswordInput`, `SearchBox`, `TextLink`, `Avatar`, `DatePager`. Modals, buttons, inputs. Flat — there is no `ui/` subfolder.
2. **`layouts/`** — the two layouts *and the pieces only they compose*, flat: `PortalShell`, `PortalHeader`, `NavTabs`, `BottomNav`, `AccountMenu`, `ClassSwitcher`. If only a layout renders it, it belongs here, not in `pages/`.
3. **`pages/partials/`** — domain components shared by more than one feature: `PostCard`, `CareEntry`, `ChatPanel`, `StudentRow`.
4. **`pages/{side}/{feature}/partials/`** — used by exactly one feature: `RoomCard`, `NewPostDialog`, `BannerGallery`, `LogEntryDialog`, `DirectoryFilters`.

**A feature partial must never be imported by another feature.** If it is, promote it to `pages/partials/` — that reach across is the signal, and the only reason `DatePager` and `StudentRow` moved. Equally, a component whose only consumer is one other component isn't shared: fold it in (`PostSlider` went inside `PostCard`).

Import through the `@` alias (`@/components/InputError`, `@/pages/partials/PostCard`) — never deep relative paths. Screens and layouts are `export default`; hooks, services, stores, and primitives are named exports.

## Composition over conditionals

**The school side and the family side are different components, not one component with role checks.** This is the explicit lesson from the preschool portal, where a single layout carried `isStaff ? tabs : tabs.filter(...)`, `{isStaff && <Link/>}`, and `{canAdmin && …}` — every screen paying rent on branches most of its users never take.

So:

- **Two layouts** — `StaffLayout` and `FamilyLayout`, each declaring its own nav items as a plain array. No filtering a shared array by role.
- **Two route trees** — `/staff/*` under `RequireStaff`, `/family/*` under `RequireFamily`. The guard is the route, not an `if` in the screen.
- **Two screens where the screens genuinely differ.** A family's view of a classroom feed and a teacher's view of the same feed are separate files that compose the same pieces.
- **Shared behaviour moves down into composable pieces**, not up into flags: `<PortalHeader>`, `<NavTabs items={…}>`, `<BottomNav items={…}>`, `<ClassSwitcher>`, `<FeedPost>`, `<ChildCard>`. Each takes data and children, and knows nothing about who's looking at it.
- The one legitimate conditional is **presence**, not role: `{post.photos.length > 0 && <PhotoGrid/>}` is fine; `{isStaff && <PhotoGrid/>}` means a missing component.

If a shared component ever needs an `isStaff`/`role` prop to decide what to render, that's the signal to split it into two and share whatever sits underneath.

## Navigation — horizontal, ported from the preschool portal

The school has already approved this shell; reproduce it, don't redesign it. Reference: [`resources/js/layouts/portal-layout.tsx`](../preschool/resources/js/layouts/portal-layout.tsx) in the preschool repo.

Three bands, all horizontal — there is no sidebar anywhere in this product:

1. **Top bar (h-16, sticky, white):** brand chip · vertical rule · class switcher pill (a Headless UI `Menu` showing the current room with a colour dot and child count per option) · `ml-auto` · context actions · avatar menu (name/email header, Settings, Log out).
2. **Second band (desktop only, `border-y`):** the primary tabs. Section pills (Home, Students) use a filled `bg-portal-soft text-portal-accent` active state; in-class tabs (Feed, Roster, Today, Chats) use a `border-b-2 border-portal-accent` underline active state. Keep both — the difference signals "section" vs "tab within this room".
3. **Bottom tab bar (mobile only, fixed):** the same destinations as icons + 11px labels, `pb-[env(safe-area-inset-bottom)]`, hideable per screen for full-bleed pages like an open chat.

**The layouts have two modes, and the band shows one or the other — never both.**

- **Top level** (not in a room): the band carries the school-wide sections as pills — Home, Students, and every feature that lands later (billing, staff, health, finance, reports). **This is where new app-level features go.** The room switcher is absent.
- **In a room**: the band carries only that room's tabs as underlines. The header gains a back link out (`All rooms` / `My children`) and the room switcher, which together are the room identity. No school-wide entry appears anywhere in room mode.

Mixing the two is what makes the band unreadable: the pill and underline treatments only differ on the *active* item, so school-wide entries read as siblings of the room's tabs — "Students" (every child in the school) beside "Roster" (this room's children), one mis-click apart. Entering a room is a **mode switch**, not another tab. The mobile bar follows the same rule: room tabs in a room, sections outside one.

Each layout owns its own arrays; families get a thinner set and never see Roster or the directory. Active state comes from the router, not from props threaded down.

Carry over the design tokens too (`--color-portal-brand`, `-ink`, `-accent`, `-soft`, `-line`, `-field`, `-bg`, `--shadow-s3`) into `styles/index.css` so the two products stay visually identical.

**The typeface is Figtree**, weights 400–800, the same family the preschool portal loads. 800 is not optional — the event day chip uses `font-extrabold`. Match the reference's weights exactly when porting a screen: `font-bold` for names and headings, `font-medium` for meta lines, `font-semibold` for action buttons, and the literal sizes it uses (`text-[13px]` room line, `text-[15px]` rail rows, `text-[10px]` comment meta).

## The feature surface being ported

The school signed off on the preschool portal, so this product starts as that portal rebuilt multi-tenant. Read the preschool implementation before building each one:

| Feature | Preschool reference | Notes for the port |
| --- | --- | --- |
| Class home | `pages/portal/home.tsx` | Google-Classroom cards: banner + title overlay, teacher list, child count, admin `···` menu (edit / archive / restore), banner gallery picker |
| Class feed | `pages/portal/class/feed.tsx` | Posts with photo grids, likes, comments, and an `event` post type carrying date/time/location |
| Roster | `pages/portal/class/students.tsx` | The room's children — staff only |
| Today | `pages/portal/class/today.tsx`, `partials/day-sheet.tsx` | Per-child daily report: nap, meal, nappy, mood, note, photo entries; drafts then publish |
| Chats | `pages/portal/class/chats.tsx` | Per-family threads plus a class announcement thread |
| Students directory | `pages/portal/students/index.tsx` | Cross-class, staff only: enrollment history, guardians, report cards, invite codes |
| Join | `pages/portal/join.tsx` | A family redeems an invite code to link to their child |
| Settings | `pages/portal/settings.tsx` | Profile |

Two things change in the port. The daycare schema is richer — `care_logs` and `incidents` replace the preschool's single `report_entries` table, and billing/health/HR have no preschool equivalent. And everything is `school_id`-scoped. Where the preschool model is thinner than `docs/db-design.md`, the db-design wins.

## Data access

**All HTTP goes through the shared axios instance in `services/index.ts`.** It attaches the bearer token and logs out on `401`; a bare `axios.get` bypasses both.

Never call `http` from a component. Wrap it in a hook in `services/{domain}.query.ts`:

```ts
export const useStudentsQuery = (schoolId: number) =>
  useQuery<Student[], ApiError>({
    queryKey: ["students", schoolId],
    queryFn: async () =>
      http
        .get("students", { params: { school_id: schoolId } })
        .then((res) => res.data.data)
        .catch(rethrowResponse)
  });

export const useEnrollStudentMutation = () =>
  useMutation<Enrollment, ApiError, EnrollPayload>({
    mutationFn: async ({ studentId, ...payload }) =>
      http
        .post(`students/${studentId}/enrollments`, payload)
        .then((res) => res.data.data)
        .catch(rethrowResponse)
  });
```

- **Always `.then((res) => res.data.data)`** — the API's `{ success, message, data }` envelope is uniform, so unwrapping belongs in the service and components never see an axios response. Paginated endpoints unwrap to the paginator, so the rows are `data.data` and the meta is `data.current_page` / `data.total`.
- **Always `.catch(rethrowResponse)`** — it re-throws `error.response`, which is what makes the `ApiError` type (`err.data.message`, `err.data.errors`) accurate at every call site. The backend's exception handler guarantees that shape for *every* status, so there is no second error format to handle.
- The envelope's `message` is written server-side to be shown ("Classroom created successfully."). Prefer toasting it over a hardcoded frontend string when a mutation succeeds.
- `queryKey` is `[domain, ...identifiers]`. Invalidate with `queryClient.invalidateQueries({ queryKey: [domain] })` in `onSuccess` after a mutation — don't hand-patch cached lists.
- **`snake_case` on the wire in both directions** — payloads hit `FormRequest::rules()`, responses come straight off the models' columns. Types in `types/` are declared with snake keys (`first_name`, `started_on`); don't camelize at the boundary, or `form.errors.classroom_id` stops matching the 422 body.

## Forms

Use the local `useForm` hook (`@/hooks/useForm`) — not react-hook-form, not raw `useState` per field. It gives dot-path `setData`, `errors`, `reset`, `isDirty`, and `formData()` for multipart.

```tsx
const form = useForm({ classroom_id: "", started_on: "" });
const enroll = useEnrollStudentMutation();

const submit = (e: React.FormEvent) => {
  e.preventDefault();
  form.clearErrors();

  enroll.mutate(
    { studentId, ...form.data },
    {
      onSuccess: () => {
        toast.success("Student enrolled");
        form.reset();
      },
      onError: (err) => form.setErrors(flattenErrors(err.data?.errors))
    }
  );
};
```

- Form data keys mirror the FormRequest's rules exactly — that's what makes `form.errors.classroom_id` line up with the 422 body.
- Map a 422's `data.errors` (arrays) onto `form.setErrors` (first message per field). Anything else surfaces as `toast.error(err.data?.message ?? "Something went wrong")`.
- Render field errors through `@/components/ui/InputError`; disable submits on `mutation.isPending` or use `LoadingButton`.
- Toasts are `react-toastify` (`toast.success` / `.error` / `.info`).

## State

- **Server data lives in TanStack Query.** Don't copy it into a store.
- **Session and cross-screen client state lives in TanStack Store** — module-level `new Store<T>()` plus an exported `*Actions` object and `use*` selector hooks (see `authStore.ts`). No Redux, no Context for state (`providers/app-providers.tsx` is for providers only).
- localStorage access is confined to `support/utils.ts` and the stores that own a key.

## Routing

Paths live in `routes/index.ts` as a single `as const` object; `routes/RouteList.tsx` maps them to elements. **Add a path to the registry and reference `routes.x`** — never hardcode a URL string in a component or `navigate("/students")` with a literal. Auth and tenancy gating happens by nesting under the `RequireAuth` / `RequireStore` layout routes, not with `if` statements inside screens.

## Styling

Tailwind 4, configured in CSS (`styles/index.css`, `styles/utils.css`) — there is no `tailwind.config.js`. Project component classes (`form-control`, `btn-brand`, `checkbox`) are defined in `utils.css`; prefer them over re-typing long utility chains, and add to that file when a pattern repeats. Compose conditional classes with `cn()` from `@/support/utils`. Icons come from `lucide-react` only. Overlays, menus, and dialogs come from `@headlessui/react`.

## Headless UI dropdowns

**Every `MenuItems` (and any other anchored floating panel) sets `modal={false}` and uses the object anchor form:**

```tsx
<MenuItems
    anchor={{ to: "bottom end", gap: 4 }}
    modal={false}
    className="…"
>
```

Without `modal={false}`, Headless UI scroll-locks the page while the menu is open — it hides overflow on `<html>` and pads for the scrollbar gap. Against this layout that puts **a second scrollbar on the body every time a dropdown opens**. A dropdown is not a modal; only a real `Dialog` should lock scrolling.

The object anchor (`{ to, gap }`) goes with it, so the offset comes from the positioner rather than a `mt-*` class it doesn't know about.

## Style rules

The frontend's formatting is **deliberately different from the PHP side** — don't carry Laravel habits across:

- **Double quotes**, **4-space indent**, **no trailing commas**, 120-column print width (150 in `.tsx`). `tabWidth` is pinned in `frontend/.prettierrc`; without it the repo-root `.editorconfig` (written for PHP) silently drives it.
- `prettier-plugin-organize-imports` sorts imports — don't hand-order them.
- `verbatimModuleSyntax` is on: type-only imports **must** be `import type { … }`.
- `noUnusedLocals` / `noUnusedParameters` are errors, not warnings.
- `npm run lint` and `npm run format` from inside `frontend/` before you're done.

## Brand copy

The product is **WODI Childcare** (one word, all caps). User-facing copy says **Families**, never "Parents" — the schema's `guardians` table and `guardian` guard are internal names and stay as they are. "Enroll" in the marketing sense links to the OneList URL.

---

# Diagnose before you change anything

**Find the cause before you touch code. A plausible explanation is not a diagnosis.**

When something is broken:

1. **Reproduce and observe first.** Read the failing output, the actual DOM/SQL/response — not the file you assume is at fault.
2. **Name the mechanism.** "Body has `height: 100%` while the shell is `min-h-screen`, so it overflows" is a diagnosis. "It's probably the global CSS" is a guess.
3. **Prove it before the fix, or make the fix prove it.** If you cannot demonstrate the mechanism, say so and investigate further — do not ship a change and hope.
4. **Fix the cause, not a symptom near it.** If a change makes the symptom go away without explaining why, it is not the fix.

**Never present a speculative change as a solution.** If you are guessing, say "I haven't confirmed this" out loud and go on looking. Shipping an unverified change costs more than the delay: it adds churn to the diff, it hides the real bug, and it burns the reader's trust in everything else you claim.

**Verify your own edits landed.** A script that prints "done" has not proved anything — grep the file, re-run the test, hit the endpoint. Report only what you actually observed.

This applies equally to the things that look obvious. Three separate bugs in this codebase came from a `date`-cast column being compared with equality; each time the *obvious* read of the code was wrong and only running it showed the truth.

# Definition of done

`composer test` and `vendor/bin/pint` pass at the root; `npm run lint`, `npm run format:check`, and `npm run build` pass in `frontend/`. New endpoints ship with their feature test; schema changes ship with the `docs/db-design.md` update.

---

# Open decisions — surface these, don't silently pick one

Four cross-cutting choices are unresolved in the code. When work touches one, say so and let it be decided rather than encoding an answer in passing:

1. **Auth transport.** `config/auth.php` defines both guards with `'driver' => 'session'` and Sanctum is still on `'guard' => ['web']`, but the SPA sends a bearer token from localStorage with `withCredentials = false`. Those are two different auth models. Cookie/stateful matches the same-origin deployment and is safer against XSS; tokens match the existing frontend code. Pick one before writing login endpoints — retrofitting is expensive.
2. **Tenant scoping mechanism.** Per-query `where('school_id', …)` versus a global scope or middleware-bound context. Cheap to decide now, invasive later.
3. **The retail vocabulary in `frontend/src`.** `X-Store-Id`, `shop_token`, `Operator`, `outlet/v1`, `RequireStore`, and the `qcommerce` routes are leftovers. Renaming them is a small mechanical change today and a large one once screens are built on top.
4. **Notification delivery — deferred, not decided.** `docs/db-design.md` says reminders ride Laravel's `notifications` table, but no migration creates it, so the first `database`-channel notification will fail at runtime. Nothing depends on it yet. Before building any reminder (invoice due, immunization due, cert expiry, document expiry), settle the channels — in-app requires `php artisan make:notifications-table`; mail-only doesn't.
