# Project Structure

**Status:** 2026-08-02 · **decisions 1–7 approved and applied.** §8 records what was
decided and why; §9 lists what remains.

## 0. What this document is

The brief asks for a directory structure to be created and explained. Most of it
already exists: **7 feature modules, 57 tables, 244 passing tests, four quality
gates green.** So this document:

1. States the **structure as built**, and why each directory exists.
2. Marks what the brief asks for that is **genuinely missing** (§7).
3. Names **seven places where the brief conflicts with what is built** (§8), with
   a recommendation for each — including **one that would leak patient data** and
   must not be adopted.

Nothing is created until §8 is settled.

---

## 1. Root

```
Clinic/
├── Modules/          ← the seven feature modules. Each removable by deleting its folder
├── app/              ← the platform every module sits on. Owns no clinical concept
├── bootstrap/
├── config/
├── database/         ← core migrations + seeders only. Module migrations live in modules
├── docs/             ← ARCHITECTURE, DATABASE, MODULE-AUTHORING, adr/
├── public/           ← index.php and built assets. Nothing else, ever
├── resources/        ← core layouts, Blade components, core lang, js/scss entry points
├── routes/           ← core routes only. Modules own theirs
├── storage/
└── tests/            ← core + architecture tests. Module tests live in modules
```

**`Modules/` is at the root, not inside `app/`.** The brief shows both — `app/Modules/`
in the root sketch and `Modules/Patients/` in the module sketch. The root position
is correct and is what is built: a module must be removable by deleting one
directory, and the runtime PSR-4 autoloader
([ADR 0001](adr/0001-custom-module-kernel.md)) maps `Modules\*` to it without
Composer, because a cPanel account has no shell to run `composer dump-autoload`
after an update.

`app/Modules/` also exists and is a different thing — see §2.

---

## 2. `app/` — the platform

```
app/
├── Actions/Fortify/     Published Fortify actions (login, password reset)
├── Console/Commands/    Core commands — audit:verify
├── Enums/               Core enums — RoleName, UserStatus
├── Foundation/          The base layer every module extends
│   ├── Access/          Permission synchronisation from module manifests
│   ├── Billing/         ChargeCollector port + null implementation
│   ├── Dto/             Base Dto
│   ├── Exceptions/      Base exception types
│   ├── Licensing/       FeatureGate, LicenseLimitGate + permissive defaults
│   ├── Query/           QueryObject — read paths, allow-listed sorting
│   ├── Repository/      BaseRepository — write paths
│   └── Service/         InteractsWithTransactions
├── Http/                Core controllers, middleware, requests (dashboard, search)
├── Models/              Core models only — User, Branch, Setting, AuditLog, Sequence
├── Modules/             THE MODULE KERNEL — not the modules themselves
│   ├── Console/         module:list, module:sync, module:make, module:enable…
│   ├── Contracts/       ModuleLifecycle
│   ├── Exceptions/
│   ├── Navigation/      NavigationBuilder — assembles the sidebar from manifests
│   └── Slots/           SlotRegistry — screen extension points
├── Providers/
└── Support/             The shared kernel
    ├── Audit/           AuditLogger, AuditHasher — hash-chained trail
    ├── Branch/          BranchContext + global scope
    ├── Concerns/        Auditable, BelongsToBranch, HasUlid
    ├── Crypto/          Encryption + HMAC blind index
    ├── Money/           Money value object + cast
    ├── Sequence/        SequenceGenerator — gapless per-branch numbering
    └── Settings/        SettingsContract + repository
```

### Why not `Core/`, `Shared/`, `Services/`, `Contracts/`, `Traits/`, `Helpers/`

The brief proposes those six. What is built uses **three** — `Foundation/`,
`Support/`, `Modules/` — and the reduction is deliberate:

| Brief | Built | Why |
|---|---|---|
| `Core/` + `Shared/` | `Foundation/` + `Support/` | "Core" and "Shared" mean the same thing to a new developer and everything ends up in whichever they open first. `Foundation/` = **abstract bases you extend**. `Support/` = **concrete services you inject**. That line is decidable |
| `app/Services/` | *(none)* | A service at application level owns no domain. Every service belongs to a module; one that does not is a sign the module boundary is wrong |
| `app/Contracts/` | inside `Foundation/` | Contracts sit with what they abstract. A flat `Contracts/` becomes a dumping ground of unrelated interfaces |
| `app/Traits/` | `Support/Concerns/` | Laravel's own convention, and the name says what they are for |
| `app/Helpers/` | *(none)* | The brief itself says "avoid global helper functions… prefer Services". Creating the folder guarantees it fills up |

---

## 3. Module structure — canonical

Marked **✅ built** / **➕ proposed** / **⚠️ see §8**.

```
Modules/Patients/
├── module.json              ✅ manifest: key, providers, requires, feature, permissions, menu
├── Module.php               ✅ install / enable / disable / uninstall / upgrade
│
├── Config/                  ➕ module-owned config, merged under modules.patients.*
├── Console/                 ➕ module-owned artisan commands
├── Contracts/               ✅ PUBLISHED interfaces — the only namespace other modules may import
├── DTOs/                    ✅ typed data across layers. Also importable
├── Enums/                   ✅ also importable
├── Events/                  ✅ also importable
├── Exceptions/              ✅ module-specific, all extend a base
├── Exports/                 ➕ Laravel Excel exports
├── Imports/                 ➕ Laravel Excel imports
├── Http/
│   ├── Controllers/
│   │   ├── Web/             ✅ Blade responses
│   │   └── Api/V1/          ➕ JSON responses. Versioned from the first endpoint
│   ├── Middleware/          ➕ module-specific middleware
│   ├── Requests/            ✅ Form Requests — all validation
│   ├── Resources/           ➕ API Resources. Never return a model
│   └── ViewComposers/       ✅ data for views this module renders into another's screen
├── Jobs/                    ➕ queued work
├── Listeners/               ✅ react to events, own and foreign
├── Mail/                    ➕ mailables
├── Models/                  ✅ Eloquent. Internal — never imported across a boundary
├── Notifications/           ➕ Laravel notifications
├── Observers/               ⚠️ §8.3
├── Policies/                ✅ authorisation
├── Providers/               ✅ registers bindings, policies, routes, views, translations
├── Queries/                 ✅ read paths (CQRS-lite) — not in the brief, explained below
├── Repositories/
│   ├── Contracts/           ⚠️ §8.4 — currently flat in Contracts/
│   └── Eloquent/            ⚠️ §8.4 — currently flat in Repositories/
├── Rules/                   ➕ custom validation rules
├── Services/                ✅ all business logic
├── Support/                 ✅ specifications shared by validation and services
├── Routes/
│   ├── web.php              ✅
│   ├── api.php              ➕ loaded under api/v1 behind Sanctum
│   └── admin.php            ➕ loaded under admin/ behind an elevated gate
├── Resources/               ⚠️ §8.5 — brief calls this Views/
│   ├── views/               ✅
│   └── lang/{en,ar}/        ✅
├── Database/
│   ├── Migrations/          ✅
│   ├── Seeders/             ✅
│   └── Factories/           ✅
└── Tests/
    ├── Feature/             ✅
    └── Unit/                ➕
```

### Two directories not in the brief

**`Queries/`** — the brief's chain is Controller → Service → Repository → Model.
That is right for **writes**. It is wrong for reads: a DataTables feed with a
dozen optional filters, dynamic sorting and pagination forced through a
repository interface produces `search(array $filters)`, which is a query builder
wearing a disguise. So reads go Controller → **QueryObject** → Builder, with an
allow-list of sortable columns so a sort parameter from a request cannot reach
SQL unchecked ([ADR 0003](adr/0003-repositories-and-query-objects.md)).

**`Support/`** — specifications shared between a Form Request and a Service:
`PasswordPolicy`, `AllergyChecker`, `InvoiceCalculator`, `ReferenceRangeMatcher`.
Not services (they are not use cases) and not helpers (they are injected). A form
request may consult one; a form request has **no business reaching into the
service layer**, and Deptrac enforces that.

---

## 4. Module boundaries

### What may cross

**Importable across a module boundary:** `Contracts\`, `Events\`, `DTOs\`,
`Enums\`. Nothing else. `Models\`, `Services\`, `Repositories\`, `Queries\` are
internal and may change in any release.

This is not a convention — `tests/Architecture/ModuleBoundaryTest.php` fails the
build on violation.

### The five mechanisms

| # | Mechanism | For | Example |
|---|---|---|---|
| 1 | **Directory** | reading another module's data | `PatientDirectory::summariesFor([ids])` → array of DTOs, batched so the fast path is also the correct one |
| 2 | **Event** | telling the world something happened | `EncounterClosed` → Prescriptions abandons drafts, Billing discards empty invoices. Consultations knows neither |
| 3 | **Published contract** | a synchronous write with a return value | `AppointmentServiceInterface::transition($id, $status)` |
| 4 | **Screen slot** | putting a panel on another module's screen | `encounter.sidebar` — Consultations renders prescribing, lab and charges panels it has never heard of |
| 5 | **Port in core** | a concern another module owns but you cannot depend on | `App\Foundation\Billing\ChargeCollector` — Laboratory charges without importing Billing |

Mechanisms 4 and 5 are documented in
[ADR 0015](adr/0015-screen-slots-for-module-panels.md) and
[ADR 0017](adr/0017-laboratory-results-and-the-charging-port.md).

### Foreign keys still cross module boundaries

Deliberately. Isolation is enforced in **code**; referential integrity is
enforced in the **database**. An orphaned `patient_id` in a single MySQL database
is a corrupted clinical record, and no amount of architectural purity is worth
that.

---

## 5. Request lifecycle

Traced through a real endpoint — `POST /invoices/{invoice}/items`:

```
1  HTTP request
2  Global middleware        session, CSRF, security headers, CSP
3  Route middleware         auth, branch context resolved into BranchContext
4  Route model binding      {invoice} resolved by ULID, branch global scope applied
5  Form Request             StoreInvoiceItemRequest — rules(), messages(), validated()
                            ↳ fails → 422 with the one error envelope
6  Controller               authorize('update', $invoice)   ← Policy
                            ↳ builds a DTO from validated data
                            ↳ calls exactly one service method
                            ↳ returns redirect / API Resource
7  Service                  InvoiceService::addLine()
                            ↳ opens a transaction
                            ↳ guards invariants → throws a domain exception
                            ↳ Repository / Model writes
                            ↳ Support: InvoiceCalculator recomputes every total
                            ↳ AuditLogger records the change
                            ↳ dispatchAfterCommit(DomainEvent)
8  Listeners                run after commit, never inside the transaction
9  Response                 Blade view, or API Resource — never a bare model
```

**The controller does four things and nothing else:** authorise, build a DTO,
call one service, return. If a controller grows a fifth responsibility, the
service is missing a method.

---

## 6. Service, repository and query flow

```
WRITE     Controller → Service → Repository → Model
                          ↓
                    Support (specifications)
                          ↓
                    AuditLogger + Events

READ      Controller → QueryObject → Builder → Model
          (cross-module)  → Directory → DTO
```

**Enforced by Deptrac**, not by discipline:

| Layer | May depend on |
|---|---|
| Controller | Service, FormRequest, Model *(type-hints only)*, Query, ModuleSupport, Foundation, Kernel |
| ViewComposer | Service, Query, Model, ModuleSupport, Foundation, Kernel |
| FormRequest | Model, ModuleSupport, Foundation |
| Service | Repository, Query, Model, ModuleSupport, Foundation, **Eloquent** *(owns the transaction boundary)* |
| Repository / Query | Model, ModuleSupport, Foundation, Eloquent |
| Model | Foundation, Eloquent |
| Foundation | Eloquent, Model |
| Kernel | Foundation only |

A controller cannot reach Eloquent or open a transaction. That rule is what keeps
the promise that a mobile app reuses the same business logic honest.

---

## 7. What is genuinely missing

| Area | Status |
|---|---|
| **API layer** | `routes/api.php` has `v1/me` only. **No API Resources anywhere. No module `Routes/api.php`. No `Http/Controllers/Api/V1`.** The largest gap |
| `admin.php` per module | None |
| `Jobs/` | None — PDF generation, exports and image work are synchronous today |
| `Notifications/` + `Mail/` | None. Needs the `notifications` tables from [DATABASE.md](DATABASE.md) §4.8 |
| `Observers/` | None — see §8.3 |
| `Exports/` / `Imports/` | None. `maatwebsite/excel` is not installed |
| `Rules/` | None — validation is inline rules today |
| `Http/Middleware/` per module | None |
| `Config/` per module | None — settings are in the database, which is mostly right |
| `Console/` per module | None |
| `Tests/Unit/` per module | Unit tests live in `tests/Unit/`; feature tests live in modules |
| **Enums** | `VisitType` and `NotificationType` missing. Gender, BloodGroup, UserStatus, AppointmentStatus, InvoiceStatus, PaymentStatus **all exist** |
| **Frontend** | SweetAlert2, Tom Select, Flatpickr **not installed**. Bootstrap 5, Bootstrap Icons, Chart.js, DataTables, FullCalendar, jQuery are |

---

## 8. Decisions taken

All seven were settled on 2026-08-02. Items 8.1–8.7 below record the reasoning;
what changed is summarised here.

| Decision | Outcome |
|---|---|
| 8.1 PHI on a private disk | **Confirmed as built.** `storage/app/public` rejected |
| 8.2 `app/Modules` for business modules | **Adopted — modules moved.** Kernel renamed to `App\ModuleKernel` to keep the layer rules separable |
| 8.3 Numbering in services, not observers | **Confirmed as built** |
| 8.4 `Repositories/Contracts/` split | **Adopted** — and it immediately exposed three controllers reaching past the service layer into repositories, now fixed |
| 8.5 `Resources/` over `Views/` | **Confirmed as built** |
| 8.6 `Database/Factories/` only | **Confirmed as built** |
| 8.7 No `Helpers/` | **Confirmed** — none created, none will be |

### 8.0 What the module move actually cost

412 files rewritten across two ordered phases, plus `composer.json`,
`phpunit.xml`, `phpstan.neon`, `deptrac.yaml`, `config/modules.php`,
`tests/Pest.php` and the architecture test. Two defects surfaced and were fixed:

- **The factory resolver hardcoded a namespace depth of 2.** `App\Modules\X`
  has three segments before the model, so every module factory resolved to
  `App\Modules\Database\Factories\…` and 149 tests failed at once. It now derives
  the depth from `config('modules.namespace')`.
- **The boundary test read the module name at a fixed segment index**, so after
  the move every module appeared to be importing itself.

Both were literals standing in for something the configuration already knew.
That is the recurring shape of this class of bug, and both are now derived.

---

## 8a. The reasoning behind each decision

### 8.1 `storage/app/public` for patient files — **do not adopt**

The brief says *"Always use Laravel Storage. `storage/app/public`"* and lists
`patients/`, `documents/`, `prescriptions/`, `reports/`.

**`storage/app/public` is served to the internet without authentication.**
`php artisan storage:link` symlinks it to `public/storage`, so
`https://clinic.example/storage/patients/…` returns the file to anyone who
guesses or is sent the URL. Putting lab reports, scans or prescriptions there is
a PHI breach reachable by URL alone, with no audit entry.

**What is built instead:** a `private` disk rooted at `storage/app/private`,
outside the web root, with every download passing through a controller that
checks the policy and writes a `record_access_logs` row.

```php
// config/filesystems.php — as built
'private' => ['root' => storage_path('app/private'), 'visibility' => 'private'],
'backups' => ['root' => storage_path('app/backups'), 'visibility' => 'private'],
'public'  => ['root' => storage_path('app/public'),  'visibility' => 'public'],
```

**Recommendation: keep the private disk for everything clinical.** `public` stays
for the clinic logo and theme assets only. The folder names you listed are
adopted *within* the private disk. This is the one item in the brief I will not
implement as written.

### 8.2 `app/Modules/` for business modules — **adopted**

Business modules now live at `app/Modules/<Name>` under `App\Modules\<Name>`.

The kernel that discovers, autoloads, licences and boots them moved to
`app/ModuleKernel` under `App\ModuleKernel`. **This rename was forced, not
cosmetic:** Deptrac's `Kernel` layer is `#^App\ModuleKernel\.*#`, and had the
kernel stayed at `App\Modules\` it would have matched every module class,
collapsing all seven layer rules into one.

The runtime autoloader ([ADR 0001](adr/0001-custom-module-kernel.md)) is
unaffected — it reads each module's namespace from its own `module.json` and was
never tied to a particular root. A module shipped by the updater onto a cPanel
host with an authoritative classmap is still resolved.

`composer.json` no longer needs a `Modules\\` PSR-4 entry; the existing
`App\\ → app/` prefix covers modules present at build time.

### 8.3 Observers for MR and invoice numbers — **recommend against**

The brief asks for `PatientObserver` to generate the MR number and an observer to
generate the invoice number.

Both numbers come from a **gapless per-branch sequence** allocated under
`SELECT … FOR UPDATE` inside an explicit transaction. Three problems with doing
that in an observer:

1. **The lock must be held from allocation to commit.** An observer firing on
   `creating` has no control over the transaction boundary — it inherits whatever
   the caller happened to open, or none.
2. **Observers fire on every create**, including factories, seeders and imports.
   A test that builds 50 patients would consume 50 MRNs from the live sequence.
3. **It becomes invisible.** "Where does the MRN come from?" has no answer a
   reader can find from the service that registers patients.

**What is built:** `PatientService::register()` opens the transaction, allocates
through `SequenceGenerator`, and rolls the number back with the patient if
registration fails — which there is a test for.

**Recommendation: keep numbering in services.** Observers are welcome for genuinely
automatic, side-effect-free work — `ulid` assignment already uses a model boot
hook, which is the same idea. If you want the folder for that purpose, say so and
I will add it.

### 8.4 `Repositories/Contracts/` — **adopted, and it found real violations**

`Contracts/` held both `PatientDirectory` (published, meant to cross a boundary)
and `PatientRepositoryInterface` (internal, not meant to). Since the boundary
test allows cross-module import of `Contracts\`, a repository interface was
importable when it should not have been.

Three interfaces moved to `Repositories\Contracts\`:
`PatientRepositoryInterface`, `UserRepositoryInterface`,
`RoleRepositoryInterface`. The boundary segment now reads `Repositories`, which
is not on the allow-list, so the existing rule refuses it with no rule change.

**The move immediately exposed three Deptrac violations that had been invisible
for months.** `RoleController`, `UserController` and `PatientSearchController`
all depended directly on a repository interface — which the Controller layer
rule forbids — but while those interfaces sat under `Contracts\`, the
`#.*\\Repositories\\.*#` layer regex never matched them and Deptrac classified
them as ordinary contracts.

All three are fixed:

- `PatientSearchController` now uses `PatientDirectory`, the published read
  path, which returns `PatientSummary` DTOs instead of `Patient` models — the
  right answer for an autocomplete endpoint, which is the easiest thing in an
  application to enumerate.
- `RoleController` and `UserController` go through `RoleServiceInterface`, which
  gained `all()` and `permissionsByGroup()`.

A controller that can reach a repository can reach every method on it, including
the writes. A new architecture test now fails the build if any
`*RepositoryInterface` reappears in a published `Contracts/` namespace.

### 8.5 `Views/` vs `Resources/views/`

`Resources/` also holds `lang/`. Splitting gives `Views/` and `Lang/` as
siblings, against Laravel's own `loadViewsFrom` / `loadTranslationsFrom`
convention.

**Recommendation: keep `Resources/`.** Cosmetic either way — say the word.

### 8.6 `Factories/` at module root

The brief lists both `Factories/` at module root and `Database/Factories/`.
Laravel resolves the second automatically.

**Recommendation: `Database/Factories/` only.**

### 8.7 `Helpers/` directories

The brief says *"Avoid global helper functions unless absolutely necessary. Prefer
Services"* — then lists `Helpers/` in both `app/` and every module.

**Recommendation: do not create them.** An empty folder named Helpers fills up.

---

## 9. Remaining work

| Step | Work | Size |
|---|---|---|
| ~~1~~ | ~~`Repositories/Contracts/` split~~ | **Done** |
| 2 | `VisitType`, `NotificationType` enums | Small |
| 3 | Install SweetAlert2, Tom Select, Flatpickr; wire into the component library | Small |
| 4 | **API layer**: `Http/Resources/`, `Http/Controllers/Api/V1/`, module `Routes/api.php`, Sanctum tokens, `api/v1` prefix, contract tests | **Large — own phase** |
| 5 | `Jobs/` + move PDF generation, exports and image work onto the queue | Medium |
| 6 | `Notifications/` + `Mail/` + the notification tables | Medium — needs DATABASE.md §4.8 |
| 7 | `Exports/` + `Imports/` (`maatwebsite/excel`) | Medium |
| 8 | `Rules/`, `Http/Middleware/`, `Console/`, `Config/` per module as needed | Small, incremental |

Steps 1–3 are a single pass. **Step 4 is the significant one** and should be its
own phase with its own architecture test proving every API endpoint calls the
same service its web counterpart does.

No directory is created empty. Each appears when the first class that belongs in
it is written — `module:make` will scaffold the full set for new modules.
