# 0010. A generic property system for storages and locations

- **Status:** Accepted
- **Date:** 2025-07-06

## Context

Storage units have attributes that vary by site and change over time: dimensions, floor, access hours,
climate control, electricity, camera coverage. Locations have their own set. Every one of them started as a
feature request, and the back office needs to add more without a deploy.

## Decision

An **editor-defined property system**, parallel structures for storages and locations:

```
StoragePropertyGroup            grouping in the UI
  └─ StorageProperty            name, codeName (unique), type, displayInContract, isPublished
       ├─ StoragePropertyOption the choices, for SELECT / MULTISELECT
       └─ StoragePropertyValue  storageId + storagePropertyId + one of four value columns
```

Five types — `TEXT`, `NUMBER`, `SELECT`, `MULTISELECT`, `BOOLEAN` — and four nullable value columns
(`valueString`, `valueInteger`, `valueBoolean`, `storagePropertyOptionId`). **Validation rules pick which
column is required from the property's type**, using conditional `when` closures.

`codeName` is unique and is how code refers to a property without hard-coding an id.

Locations got the same structure later (`m251120_144026_location_properties.php`).

## Alternatives

- **A column per attribute.** A migration for every new one, and the back office cannot add any.
- **A JSON blob on the storage.** No types, no validation, no options, not filterable in SQL.
- **One shared property system for both storages and locations.** Rejected: the two are edited by different
  people at different times, and a shared table would need a polymorphic owner. Duplicating the shape was
  cheaper than generalising it.

## Consequences

- **Editors add attributes without a deploy**, including new dropdown options.
- **The storage filter can be built from properties**, which is what `StorageSearchService` does.
- **Reading a property is a join, not a column.** `StorageService::sizes()` reaches through
  `$storage->width?->valueInteger` and formats `0 × 0 × 0 cm` when the properties are absent, rather than
  failing.
- **`isEmpty` callbacks are mandatory** on these rules — `helpers/Validation.php` supplies `isEmptyInt`,
  `isEmptyBool`, `isEmpty` — because `0` and `false` are valid values that Yii's default emptiness check
  would reject.
- Two parallel structures means a change to the concept has to be made twice.
- `displayInContract` exists because the rental contract shows a different subset than the website. See
  [0019](0019-documents-generated-in-app.md).
- **The validation rules are dense, and nothing exercises them.** Values are always written with
  `save( false )`, so `StoragePropertyValue::rules()` has never run against the 14 040 rows in the table. Two
  of its rules were wrong because of that and were corrected in 2026-08. See
  [wiki/STORAGE-AND-LOCATIONS.md](../wiki/STORAGE-AND-LOCATIONS.md).
