<!--
	covers: services
	verified: 1a800d8
-->
# Services

[`services/`](../../services), 20 classes, ~2500 LOC. **Two unrelated kinds of class share the directory** —
check which one you are dealing with before assuming an API.

## Kind 1 — static repositories

The majority. No base class, no interface, no instances: a class of `public static` methods over one table.

```php
class AreaService {

    public static function published() : array {
        static $published;
        $published ??= static::query()->all();
        return $published;
    }

}
```

| Service | Reads |
|---------|-------|
| `AreaService` | `Area` |
| `AddressService` | `Address` |
| `ArticleService` | `Article` |
| `ContentService` | `Content` — plus named lookups: `cookie()`, `terms()`, `privacy()` |
| `FaqService` | `Faq` |
| `LocationService` | `Location` |
| `ServiceService` | `Service` |
| `StorageService` | `Storage` — area bounds, price and size formatting |
| `AvailabilityService` | whether a unit is free — the **only** owner of that rule |
| `StorageTypeService` | `StorageType` |
| `StoragePropertyService` | the storage property system |
| `StorageSearchService` | the search/filter query — the biggest at 478 LOC |
| `LegacySourceService`, `ImportDiffService`, `SpreadsheetService` | the Drupal re-import — see [MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md) |
| `ComparisonService`, `ProposalRequestService` | session-backed lists (see below) |
| `SessionListService` | the shared session-list mechanism |

### Two caching layers, both per-request

1. **`static $x; $x ??= …`** inside the method — memoizes for the life of the PHP process. This is the
   dominant pattern; every repository service uses it.
2. **`->cache()`** on the query — Yii's query cache, backed by the shared cache component with
   `DB_QUERY_CACHE_DURATION` (900 s).

The two stack: the first call hits the query cache, subsequent calls in the same request do not touch the
cache component at all.

> **A `static` variable inside a static method is shared across every call in the process.** In a web request
> that is exactly what is wanted. In a **console command that loops over records**, it means the first
> result is frozen for the whole run. `AvailabilityService` builds its entire map on first call for this
> reason — but a service written assuming a single request will quietly serve stale data in a cron.
>
> Two services that a console command has to be careful with:
>
> - **`AvailabilityService::reset()`** exists for exactly this. Call it after writing availability.
> - **`Storage::getValues()`** (a model, not a service, but the same trap) memoizes **every** property value
>   row in a process-wide static, which is why `Storage::updateComputedProperties()` cannot be trusted to
>   maintain `area` and `volume` during an import. See [MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md).
>
> `ImportDiffService` keeps its memoization in **class properties rather than method statics**, precisely so
> `reset()` can drop it.

### `AvailabilityService` is the odd one

It answers "is this unit free" for the whole application, and it answers it for **every** unit at once:
three cached queries — the manual overrides, the live rentals, the open quote requests — become a
`storageId => state` map and a `storageId => intervals` map, and every call after that is an array lookup.

Because the three sources are memoized rather than consumed, it can also answer for **another day** —
`occupiedIdsOn( $date )`, which the storage list's start-date facet needs — at no extra query.

Calling it for one unit costs the same as calling it for five hundred. That is the point: a location page
colours up to a hundred units and the search listing filters five hundred. `StorageService::usage()` and
`StorageSearchService::occupiedIds()` are now three-line delegations to it. See
[ADR 0034](../adr/0034-the-availability-rule-has-one-owner.md) and
[STORAGE-AND-LOCATIONS.md](STORAGE-AND-LOCATIONS.md#occupancy).

> **`SpreadsheetService` both reads and writes**, which no other service does. Its column list is the schema
> of the export *and* the import, and splitting the directions would mean two copies of it. A deliberate
> exception — see [ADR 0036](../adr/0036-the-excel-round-trip-is-xlsx.md).

## Kind 2 — integration clients

These are **instantiated**, hold state, and implement an interface.

| Class | Role |
|-------|------|
| [`GateServiceInterface`](../../services/GateServiceInterface.php) | the contract |
| [`AbstractGateService`](../../services/AbstractGateService.php) | shared behaviour |
| [`TellGateService`](../../services/TellGateService.php) | the real TELL Gate Control PRO HTTP client |
| [`MockGateService`](../../services/MockGateService.php) | used when `APP_GATE_SYNC_FAKE` |
| [`TellApiLog`](../../services/TellApiLog.php) | writes `runtime/logs/tell-api.log` |
| [`AiTranslationService`](../../services/AiTranslationService.php) | Anthropic Messages API client |

See [GATE-SYNC.md](GATE-SYNC.md) and [I18N-AND-URLS.md](I18N-AND-URLS.md).

## Name collision to remember

```
services/ProposalRequestService.php   → app\services\ProposalRequestService   (the service)
models/ProposalRequestService.php     → app\models\ProposalRequestService     (a join record)
```

Both exist, both are used. The `use` statement decides which one you got, and the class names are identical —
this has bitten before.

## When to add a service

- A **read path over a table** that more than one caller needs → static repository service.
- An **external system** → interface + implementation + mock, following the gate pattern.
- Anything that needs per-request memoization of an expensive aggregate.

Not a service: single-model logic (put it on the model), formatting (`helpers/`), or markup (`widgets/`).
