# Brands Design

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-25

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

## Data Model

### `brands` table 🟢

| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| `id` | integer | PK, auto-increment | Sortable in the grid |
| `name` | string | NOT NULL | Required by `form()` validation rule |
| `description` | text / string | nullable | Free text |
| `created_at` | timestamp | — | Framework default |
| `updated_at` | timestamp | — | Framework default |

### `Brand` model 🟢 (`Brand.php:9-12`)

- `Brand::products()` — `hasMany(Product::class)` via `products.brand_id`

### Entity Relationship

```mermaid
erDiagram
    brands {
        int id PK
        string name
        string description
        timestamp created_at
        timestamp updated_at
    }
    products {
        int id PK
        int brand_id FK
    }
    brands ||--o{ products : "brand_id (required FK)"
```

The `products.brand_id` foreign key is required — every product must belong to a brand. This relationship is the sole reason brands cannot be deleted. 🟢 (`Brand.php:9-12`)

---

## Internal Flows

### GET `settings/brand` — list + inline create

```mermaid
sequenceDiagram
    participant Client
    participant AdminMiddleware
    participant BrandController
    participant Grid
    participant WidgetsForm

    Client->>AdminMiddleware: GET settings/brand
    AdminMiddleware-->>Client: 302 auth/login (if unauthenticated)
    AdminMiddleware->>BrandController: index()
    BrandController->>Grid: grid()->render()
    BrandController->>WidgetsForm: Widgets\Form(action=settings/brand, name+description)
    BrandController-->>Client: 200 Admin::content (two-column: Grid | WidgetsForm in Box)
```

🟢 (`BrandController.php:19-40`)

### POST `settings/brand` — create via inline form

```mermaid
sequenceDiagram
    participant Client
    participant ModelFormTrait
    participant FormMethod

    Client->>ModelFormTrait: POST settings/brand {name, description}
    ModelFormTrait->>FormMethod: form() — validate name required
    alt validation fails
        FormMethod-->>Client: 302 back with errors
    else validation passes
        FormMethod->>DB: INSERT brands (name, description)
        FormMethod-->>Client: 302 back to list
    end
```

🟢 (`BrandController.php:75-81`)

### GET `settings/brand/{id}/edit` + PUT `settings/brand/{id}` — edit

```mermaid
sequenceDiagram
    participant Client
    participant BrandController
    participant ModelFormTrait

    Client->>BrandController: GET settings/brand/{id}/edit
    BrandController->>BrandController: form()->edit($id)
    alt id not found
        BrandController-->>Client: 404
    else found
        BrandController-->>Client: 200 edit form
    end

    Client->>ModelFormTrait: PUT settings/brand/{id} {name, description}
    ModelFormTrait->>DB: UPDATE brands SET name, description WHERE id={id}
    ModelFormTrait-->>Client: 302 back to list
```

🟢 (`BrandController.php:42-48,75-81`)

### Row-action gate logic (`grid()`) 🟢 (`BrandController.php:65-70`)

```
for each row action:
    disableDelete()              ← unconditional, every row
    if rowKey == 1:
        disableEdit()            ← default brand (id 1) is edit-locked
```

---

## Technical Decisions

| Decision | Rationale extracted from code | Confidence |
|----------|-------------------------------|------------|
| **Encore\Admin `ModelForm` trait** — `store`/`update`/`destroy`/`show`/`create` are trait defaults driven by `form()` | Minimal boilerplate; only `index`/`edit`/`grid`/`form` are overridden | 🟢 (`:17`) |
| **Inline create widget on the index screen** — `Widgets\Form` in the right column; `disableCreateButton()` hides the grid's own create button | Avoids a separate `/create` route; one-screen CRUD for a small catalogue | 🟢 (`:28-37,59`) |
| **Brands are permanently undeletable** — `$actions->disableDelete()` runs unconditionally | `products.brand_id` is a required FK; deleting a brand would orphan products | 🟢 (`:69`; `Brand.php:9-12`) |
| **Default brand (id 1) is edit-locked** — `in_array($actions->getKey(), [1]) → disableEdit()` | Protects the mandatory fallback brand; purely a UI gate, not a DB constraint | 🟢 (`:66-68`) |
| **Unpaginated, unfiltered grid** — `disableFilter()`, `disablePagination()`, `disableExport()`, `disableRowSelector()` | Assumes the brand catalogue will remain short | 🟡 (`:60-63`) |
| **HTML/form responses only; no JSON variant** | Encore\Admin's server-rendered paradigm; no API consumer | 🟢 |

---

## Notes

### Constraints and pitfalls

- **Id-based default-brand lock is fragile.** 🟡 The edit lock keys off the literal value `1` (`BrandController.php:66`). If the default brand is re-seeded with a different id, the lock silently protects the wrong row. A slug- or flag-based approach (T-06 in `tasks.md`) is the suggested mitigation.
- **Unbounded grid.** 🟡 `disablePagination()` renders all rows at once. Fine for a short list, but as the catalogue grows the page will load all brands without limit (`BrandController.php:63`).
- **UI gates are not server-side authorization.** 🟡 The edit/delete disablement lives in `grid()` presentation logic. The underlying resource routes for `create`, `show`, `destroy` still exist and are reachable by authenticated admins who bypass the UI. No controller-level guard enforces the "permanent brands" invariant.
- **No per-record authorization.** 🟡 Any authenticated admin can create or edit any non-locked brand. (ADR-0009)
- **No observability.** 🔴 Brand mutations (`store`, `update`) emit no structured log, metric, or trace. Only framework-default behaviour applies (`BrandController.php`, absence).
