<!--
	covers: applications base components config db functions.php Yii.php web/index.php yii
	verified: 54fd0a6
-->
# Architecture

Yii 2 monolith, PHP 8.3, MariaDB (10.6 locally, 10.11 on production), PSR-4 root namespace `app\` mapped to
the repository root. One deployment, one brand, three languages. No multi-tenancy — the sibling Molino
project's `Instance` concept has no equivalent here, and nothing is scoped by tenant.

## Layers

```
web/index.php ──┐
                ├─► Application ─► UrlManager ─► Controller ─► View ─► Widget ─► views/widgets/*.php
yii ────────────┘                                    │                    │
                                                     ▼                    ▼
                                              services/*Service      db/ActiveRecord
                                                     │                    │
                                                     └────────► MariaDB ◄─┘
```

| Layer | Directory | Responsibility |
|-------|-----------|----------------|
| Entry | `web/index.php`, `yii` | load `env.php` → `functions.php` → autoloader → config, then run |
| Application | `applications/` | `WebApplication` resolves the language; `ConsoleApplication` is bare |
| Routing | `base/UrlManager.php`, `config/components/urlManager.php` | language-aware rules, sets `Yii::$app->language` |
| Controllers | `controllers/` (frontend), `commands/` (console), `modules/admin/controllers/` | resolve and delegate; expose SEO state as public properties |
| Views | `views/`, `views/widgets/` | markup only, computed values at the top |
| Widgets | `widgets/` + `base/Widget.php` | self-contained fragments with their own view, SCSS and JS |
| Services | `services/` | static read paths and external integrations |
| Models | `models/` + `db/ActiveRecord.php` | data, schema, slugs, translations, search indexing |
| Helpers | `helpers/` | stateless utilities |
| Components | `components/` | `View`, `User`, `Mailer`, `Sitemap` |
| Tests | `tests/` | PHPUnit unit tests, no external dependency — see [../TESTING.md](../TESTING.md) |

## Scale

| Area | Files | LOC |
|------|-------|-----|
| `modules/admin/` | 152 | 13 871 |
| `models/` | 74 | 11 532 |
| `views/` | 81 | 4 371 |
| `services/` | 20 | 2 499 |
| `tests/` | 18 | 2 109 |
| `widgets/` | 39 | 1 908 |
| `controllers/` | 16 | 1 915 |
| `helpers/` | 13 | 1 821 |
| `commands/` | 11 | 1 736 |
| `db/ActiveRecord.php` | 1 | 804 |

The admin module is the single biggest subsystem — nearly a third of the codebase. It is a homegrown
declarative CRUD framework, not a collection of hand-written screens. See [ADMIN-MODULE.md](ADMIN-MODULE.md).

## What lives where — the non-obvious bits

- **`db/` holds exactly one class**, `app\db\ActiveRecord`, and everything in `models/` extends it. It is the
  most load-bearing file in the project. See [ACTIVE-RECORD.md](ACTIVE-RECORD.md).
- **`base/` holds two framework extensions**: `UrlManager` (language resolution) and `Widget` (render, cache,
  naming). Neither is a namespace for general base classes — frontend controllers extend
  `app\controllers\Controller`, which lives in `controllers/`.
- **`models/forms/`** holds the form models (`Login`, `Register`, `Offer`, `StorageSearch`, …). They extend
  `app\models\forms\Form`, not `ActiveRecord`.
- **`services/` mixes two kinds of class.** Most are static repositories over a table
  (`StorageService`, `LocationService`, …). A few are integration clients with instance state:
  `TellGateService` / `MockGateService` behind `GateServiceInterface`, `AiTranslationService`,
  `SessionListService`. Do not assume a class in `services/` is static.
- **`ProposalRequestService` is a model, not a service.** `models/ProposalRequestService.php` is the join
  record between a proposal request and a `Service`; `services/ProposalRequestService.php` is the service. Two
  different classes, same base name, different namespaces.
- **`captcha/`** holds a single `CaptchaAction` used by the public forms.
- **`data/`** holds import payloads for the legacy migration, not seed fixtures.

## Configuration

`env.php` (not in Git) defines every global constant. Nothing reads `getenv()`; configuration is compile-time
constants, so a missing constant is a fatal error, not a silent default.

```
env.php
  └─ config/config.php                  components, aliases, params
       ├─ config/environments/web.php   + errorHandler, request, session, user, admin & debug modules
       └─ config/environments/console.php  + controllerNamespace, urlManager hostInfo
```

There is **no test environment configuration** and no `web/index-test.php`, despite `YII_ENV` accepting
`'test'`. `YII_ENV = 'test'` on a server only changes debug/log behaviour and the `View`'s host rewriting.

## Cross-cutting behaviour to know before touching anything

1. **Every model save can trigger three side effects**: search indexing, translation queueing, and (through
   the admin) a cache flush. See [ACTIVE-RECORD.md](ACTIVE-RECORD.md).
2. **`ActiveRecord::save()` and `delete()` never throw** — they catch, log and return `false`.
3. **`Widget::widget()` swallows exceptions outside `dev`** (`base/Widget.php:60`), returning an empty string.
   A widget that silently disappears in production is this, not a routing problem.
4. **The whole page passes through a string-replace pass** before it is sent —
   `components/View.php:216` substitutes `{email}`, `{phone}`, `{address}` and every `Settings` attribute in
   `{snake_case}` form. Literal curly braces in markup are not safe.
