---
title: "Villes"
description: "Triplet /api/cities (list paginé) / /api/cities/search (full-text + géo) / /api/cities/{id} (détail) adossé à l'index Meilisearch MEILISEARCH_CITIES_INDEX…"
---

Triplet `/api/cities` (list paginé) / `/api/cities/search` (full-text + géo) / `/api/cities/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_CITIES_INDEX` (défaut **`cities`**). Catalogue ~10 000 documents (montée en charge progressive via le pipeline d'alimentation externe).

Comme pour les établissements et les offres, Hydrogen **n'a pas de domaine `City` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un processus externe. Pas de re-hydratation SQL.

**Identité côté API** : contrairement à `/api/establishments/{id}` (32-hex) et `/api/offers/{id}` (32-hex), le `id` JSON:API d'une ressource `cities` est un **UUID dashé lowercase** (ex: `67104949-52b7-11f1-96d5-00155dda08de`). La regex de path `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` est **stricte** sur la casse — uppercase ⇒ `404` (URL space canonique).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `CityHitFormatter`, qui :
1. recopie tous les champs du document dans `attributes`, sauf les clés Meili-internes (`_geo`, `_geoDistance`, `_formatted`, `_matchesPosition`, `_rankingScore`, `_rankingScoreDetails`) et le `id` brut ;
2. aplatit `_geo: { lat, lng }` en `attributes.latitude` / `attributes.longitude` ;
3. expose `_geoDistance` (mètres) en `attributes.distanceMeters` quand le tri géo est actif (search en mode géo uniquement) ;
4. normalise `country_id` / `region_id` / `subregion_id` en **lowercase** (cohérence avec `/api/countries/{code}`, `/api/regions/{code}`, `/api/subregions/{code}`).

Champs métier exposés tels quels : `slug`, `codes{osm, wikidata, wikipedia}`, `names`, `official_names`, `alt_names`, `stats{stats, surface}`. Le pipeline source stocke certains de ces champs à `null` selon la couverture data (`names`, `alt_names`, `official_names` notamment) — Hydrogen les passe à l'identique.

**Embed hiérarchique** : chaque ressource `cities` est enrichie de 3 blocs inline :
- `country` — résolu via `CountrySummaryResolver` contre l'index `countries`.
- `region` — résolu via `RegionSummaryResolver` contre l'index `regions`.
- `subregion` — résolu via `SubregionSummaryResolver` contre l'index `subregions`.

Les 3 résolveurs font du **batch loading** (1 round-trip Meili par niveau quel que soit le nombre de villes sur la page — pas de N+1). Si un code ISO ne résout pas dans son index parent, le bloc vaut `null` (l'index `subregions` ne couvre actuellement que ~99 codes ⇒ la majorité des villes auront `subregion: null` ; c'est une question de couverture data, pas un bug).

## `GET /api/cities`

Listing paginé du catalogue, avec filtres hiérarchiques cumulables (AND). Pas de full-text ni de géo ici — voir `/api/cities/search`.

- **Auth** : aucune.
- **Action** : [ListCitiesAction](../src/Http/Action/Api/City/ListCitiesAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `country` (ISO 3166-1 alpha-2, casse insensible, normalisée upper côté serveur) — filtre `country_id`.
  - `region` (ISO 3166-2, 2..7 chars alphanum + tiret, casse insensible) — filtre `region_id`.
  - `subregion` (ISO 3166-2, même contrainte) — filtre `subregion_id`.
- **Tri** : aucun `?sort=` n'est exposé. L'index `cities` n'a que `_geo` en `sortableAttributes` (réservé au search). Ordre = ordre naturel Meili (= ordre d'insertion).

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "cities",
      "id":   "67104949-52b7-11f1-96d5-00155dda08de",
      "attributes": {
        "slug":         "paris",
        "codes":        { "osm": null, "wikidata": "Q90", "wikipedia": "fr:Paris" },
        "names":        null,
        "official_names": null,
        "alt_names":    null,
        "stats":        { "stats": "2133111", "surface": "105.3" },
        "country_id":   "fr",
        "region_id":    "fr-cvl",
        "subregion_id": "fr-75c",
        "latitude":     48.8588897,
        "longitude":    2.32004102172,
        "country":      { "id": "fr",     "name": "France",        "slug": "france",       "continent": "Europe", "continentId": "eu" },
        "region":       { "id": "fr-cvl", "name": "Centre-Val de Loire", "slug": "centre-val-de-loire", "countryId": "fr" },
        "subregion":    { "id": "fr-75c", "name": "Paris", "slug": "paris", "regionId": "fr-idf", "countryId": "fr" }
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/cities?country=FR&limit=20",
    "first": "https://api.example/api/cities?country=FR&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/cities?country=FR&limit=20&offset=20",
    "last":  "https://api.example/api/cities?country=FR&limit=20&offset=440"
  },
  "meta": {
    "totalHits": 458,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "country":   "FR"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid country code` / `Invalid region code` / `Invalid subregion code`.
  - `503 Search backend unavailable`.

## `GET /api/cities/search`

Recherche de villes par **nom/texte** (`?q=`), par **proximité GPS** (`?lat=&lng=&distance=`) ou les deux. Sémantique d'erreur identique à `/api/establishments/search` et `/api/offers/search` (geo params mutuellement requis, etc.). Les 3 filtres hiérarchiques (`country`, `region`, `subregion`) restent disponibles ici, **combinables** avec full-text et géo.

- **Auth** : aucune.
- **Action** : [SearchCitiesAction](../src/Http/Action/Api/City/SearchCitiesAction.php).
- **Query** :
  - `q` (optionnel) — recherche full-text. `searchableAttributes` est sur `*` ⇒ matche notamment `slug`, `names`, `codes.wikipedia`, etc.
  - `lat`, `lng`, `distance` — **tous les trois ou aucun**. Avec eux : filtre `_geoRadius(lat, lng, distance)` + tri `_geoPoint(lat, lng):asc`. `distance` borné par `CITY_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**).
  - `country`, `region`, `subregion` — mêmes contraintes qu'au listing, **cumulables** avec full-text et géo (AND).
  - `limit` (1..50, défaut 20).
  - `offset` (≥ 0, défaut 0).

- **Pipeline** : même formatter partagé + mêmes 3 resolvers d'embed. `attributes.distanceMeters` rempli quand le tri géo est actif.

- **Réponse `200 OK`** : structure identique au listing, plus `meta.center` / `meta.distance` quand géo, et `attributes.distanceMeters` par hit.

- **Réponses d'erreur** :
  - `422 Incomplete geo parameters` — sous-ensemble de `lat`/`lng`/`distance`.
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` / `Distance too large`.
  - `422 Invalid country code` / `Invalid region code` / `Invalid subregion code`.
  - `503 Search backend unavailable`.

> **Pré-requis index Meilisearch** :
> - `_geo` dans `filterableAttributes` ET `sortableAttributes`
> - `country_id`, `region_id`, `subregion_id` dans `filterableAttributes`
> - champs texte recherchables dans `searchableAttributes` (l'index actuel a `*` ⇒ ok)

## `GET /api/cities/{id}`

Détail d'une ville par son UUID dashé.

- **Auth** : aucune.
- **Action** : [GetCityAction](../src/Http/Action/Api/City/GetCityAction.php).
- **Path** : `{id}` — regex **stricte** `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`. Le pattern de route Slim porte déjà la regex : tout id mal formé n'atteint pas l'action et renvoie un 404 router.
- **Résolution** : filtre Meili exact `id = "<uuid>"` (l'attribut `id` est filterable) — pas de full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d'UUID.

- **Réponse `200 OK`** : ressource unique `cities` avec la même shape `attributes` que les listings (formatter + 3 blocs `country`/`region`/`subregion` partagés).

- **Réponses d'erreur** :
  - `404 City not found` — id mal formé OU absent de l'index.
  - `503 Search backend unavailable`.

---
