---
title: "Marques"
description: "Catalogue borné (< 1000 documents) hébergé dans l'index Meilisearch MEILISEARCH_BRANDS_INDEX (défaut brands). Pas de domaine Brand côté MySQL — lecture seu…"
---

Catalogue **borné** (< 1000 documents) hébergé dans l'index Meilisearch `MEILISEARCH_BRANDS_INDEX` (défaut `brands`). Pas de domaine `Brand` côté MySQL — lecture seule depuis Meili, alimenté par un processus externe.

**Forme attendue du document** : un brand peut appartenir à plusieurs types, chacun avec sa propre position. Le champ `code` (slug minuscule, ex : `fram`, `cdiscount`) sert de clé pour résoudre les images du brand sur disque.

```jsonc
{
  "id":   "42",
  "name": "Acme",
  "code": "acme",
  "logoUrl": "…",
  "types": [
    { "type": "carrier", "position": 1 },
    { "type": "rental",  "position": 5 }
  ]
}
```

**Enrichissement images** : chaque ressource `brands` reçoit un attribut `images` résolu côté serveur à partir du champ `code`. L'artwork est en **SVG**, décliné en `icon` / `logo` × 3 variantes tonales `light` / `dark` / `monochrome` (le front choisit selon le thème). Fichiers servis comme **assets first-party de l'app** depuis `public/assets/images/brands/<code>/{type}-{mode}.svg` :

```json
"images": {
  "icon": { "light": "…/icon-light.svg?v=…", "dark": "…/icon-dark.svg?v=…", "monochrome": "…/icon-monochrome.svg?v=…" },
  "logo": { "light": "…/logo-light.svg?v=…", "dark": "…/logo-dark.svg?v=…", "monochrome": "…/logo-monochrome.svg?v=…" }
}
```

Chaque variante est une URL sur `APP_URL` **uniquement si le SVG existe sur disque** ; sinon `null` (placeholder côté client). Un `?v=<mtime>` invalide le cache quand le SVG est remplacé. Résolution **une seule fois par brand** (y compris en `?groupBy=type`). Un brand sans `code` exploitable donne toutes les variantes à `null`.

> **Garde anti-path-traversal** : le `code` est re-validé contre `^[a-z0-9_-]+$` (après `strtolower`) avant tout accès disque — un code malformé (`../../etc`, chemin absolu, dotfile) ne peut jamais sortir du dossier `brands/` et donne `null`/`null`.

## `GET /api/brands`

Retourne **tous** les brands en un seul appel — pas de pagination, pas de `limit`/`offset` exposés au client. Deux modes :

- **Sans `?groupBy=type`** (défaut) : une ressource **par brand**, `types[]` intégral dans les attributs. Ordre : celui de Meili (relevance si `q` fourni, sinon ordre naturel de l'index).
- **Avec `?groupBy=type`** : chaque brand est **explosé** en N ressources, une par entrée de son `types[]`. Tri global `(type ASC, position ASC)` — appliqué côté serveur, ce qui garantit que les éléments d'un même type sont **contigus** dans `data[]`. `meta.groups[]` décrit la longueur de chaque segment.

> **Id composite en mode groupé** : un brand qui apparaît dans plusieurs types est dupliqué dans `data[]`. Pour respecter la contrainte JSON:API "`(type, id)` unique par document", l'id de chaque ressource explosée devient `<brandId>:<typeName>` (ex : `42:carrier`). L'id canonique reste accessible via `attributes.brandId`.

- **Auth** : aucune.
- **Action** : [ListBrandsAction](../src/Http/Action/Api/Brand/ListBrandsAction.php).
- **Query (tous optionnels)** :
  - `q` : recherche full-text (matche les `searchableAttributes` de l'index, typiquement `name`).
  - `groupBy` : seule valeur supportée = `type`. Active l'explosion + le tri `(type, position)` + `meta.groups[]`. Toute autre valeur ⇒ `422`.
- **Pipeline** : un seul appel Meili avec `limit = BRANDS_MAX_RESULTS` (défaut **1000**), **sans `sort`** (le tri par type/position se fait PHP-side après explosion ; `types.position` côté Meili ne suffirait pas pour un ordre global sur un tableau imbriqué).

- **Réponse `200 OK`** (mode `?groupBy=type`) :

```json
{
  "data": [
    { "type": "brands", "id": "42:carrier", "attributes": { "name": "Acme",     "type": "carrier", "position": 1, "brandId": "42", "logoUrl": "…", "images": { "icon": { "light": "https://app.example/assets/images/brands/acme/icon-light.svg?v=…", "dark": "…", "monochrome": "…" }, "logo": { "light": "…/logo-light.svg?v=…", "dark": "…", "monochrome": "…" } } } },
    { "type": "brands", "id": "17:carrier", "attributes": { "name": "Globex",   "type": "carrier", "position": 2, "brandId": "17", "logoUrl": "…", "images": { "icon": { "light": null, "dark": null, "monochrome": null }, "logo": { "light": null, "dark": null, "monochrome": null } } } },
    { "type": "brands", "id": "42:rental",  "attributes": { "name": "Acme",     "type": "rental",  "position": 5, "brandId": "42", "logoUrl": "…", "images": { "icon": { "light": "https://app.example/assets/images/brands/acme/icon-light.svg?v=…", "dark": "…", "monochrome": "…" }, "logo": { "light": "…/logo-light.svg?v=…", "dark": "…", "monochrome": "…" } } } },
    { "type": "brands", "id": "08:rental",  "attributes": { "name": "Initech",  "type": "rental",  "position": 1, "brandId": "08", "logoUrl": "…", "images": { "icon": { "light": null, "dark": null, "monochrome": null }, "logo": { "light": null, "dark": null, "monochrome": null } } } }
  ],
  "links": {
    "self": "https://api.example/api/brands?groupBy=type"
  },
  "meta": {
    "totalHits": 3,
    "query": "",
    "groups": [
      { "type": "carrier", "count": 2 },
      { "type": "rental",  "count": 2 }
    ]
  }
}
```

  > Le brand `Acme` (id `42`) apparaît **deux fois** : une fois dans le groupe `carrier` (position 1) et une fois dans `rental` (position 5). C'est attendu. `meta.totalHits` reste le nombre de brands distincts côté Meili (ici 3), pas la cardinalité de `data[]`.

- **Réponse `200 OK`** (sans `groupBy`) :

```json
{
  "data": [
    {
      "type": "brands",
      "id":   "42",
      "attributes": {
        "name": "Acme",
        "code": "acme",
        "logoUrl": "…",
        "types": [
          { "type": "carrier", "position": 1 },
          { "type": "rental",  "position": 5 }
        ],
        "images": {
          "icon": { "light": "…/brands/acme/icon-light.svg?v=…", "dark": "…", "monochrome": "…" },
          "logo": { "light": "…/brands/acme/logo-light.svg?v=…", "dark": "…", "monochrome": "…" }
        }
      }
    }
  ],
  "links": { "self": "https://api.example/api/brands" },
  "meta":  { "totalHits": 1, "query": "" }
}
```

- **Garde-fou catalogue** : si `meta.totalHits > BRANDS_MAX_RESULTS`, la réponse ajoute `meta.truncated = true` + `meta.maxResults = <cap>` — signal explicite que le cap serveur a été atteint et qu'il faut bumper `BRANDS_MAX_RESULTS` côté ops.

- **Pagination** : aucune (`links` ne contient que `self`). C'est volontaire — le catalogue est petit, le client peut tout charger en mémoire et l'utiliser comme référentiel.

- **Brands sans `types[]` en mode `groupBy`** : ignorés (ils ne peuvent pas être assignés à un groupe). Pour les voir, appeler `/api/brands` sans `groupBy`.

- **Réponses d'erreur** :
  - `422 Invalid groupBy` — `groupBy` fourni avec une valeur autre que `type`.
  - `503 Search backend unavailable` — Meilisearch injoignable. Le détail Meili est propagé dans `errors[0].detail`.

> **Pré-requis index Meilisearch** (one-shot ops) :
> - les champs texte recherchables (`name`, etc.) dans `searchableAttributes`
> - **aucun pré-requis sur `sortableAttributes`** — tout le tri est PHP-side après explosion par type

---

## `GET /api/brands/{code}`

Détail public d'**une** marque, keyée par son **`code`** partenaire (ex. `fram`) — jamais l'id numérique. Lit le **même** index que le listing via un filtre `code = "…"`.

**Path params** : `code` (`[a-z0-9]{1,64}`, normalisé en minuscules).

**Réponse (200)** — ressource JSON:API unique, mêmes attributs que le mode par défaut du listing (hit sans clés Meili, `types[]` intégral) + bloc `images` (icon/logo × light/dark/monochrome) :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "brands",
    "id": "42",
    "attributes": {
      "code": "acme",
      "name": "Acme",
      "types": [ { "type": "carrier", "position": 1 } ],
      "images": {
        "icon": { "light": "…/brands/acme/icon-light.svg?v=…", "dark": "…", "monochrome": "…" },
        "logo": { "light": "…/brands/acme/logo-light.svg?v=…", "dark": "…", "monochrome": "…" }
      }
    }
  },
  "links": { "self": "https://api.example/api/brands/acme" }
}
```

**Erreurs** : `404 Brand not found` (code malformé ou inconnu), `503 Search backend unavailable`.

---
