# Bagisto Attribute Development

> Use when working with Bagisto's EAV attribute system — adding or changing an attribute, attribute family or group, reading or writing a product attribute value, or debugging a value that reads back empty or from the wrong locale or channel. Trigger phrases include "attribute", "EAV", "attribute family", "attribute group", "attribute option", "custom attribute", "value_per_locale", "value_per_channel", "product_flat", "swatch".

- Skill: `bagisto/bagisto-attribute-development` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add bagisto/bagisto-attribute-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bagisto/bagisto-attribute-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: bagisto (https://skillmd.com/u/bagisto)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bagisto/bagisto-attribute-development

---


# Attribute Development

Bagisto stores product data as **EAV** — entity, attribute, value — rather than
as columns on `products`. A product row carries almost nothing; its name, price,
description and every custom field live in `product_attribute_values`, one row
per attribute per locale per channel.

This is the concept most Bagisto mistakes trace back to, so it is worth reading
[eav.md](eav.md) before changing anything that touches product data.

## Reference files

| File | Load when |
|---|---|
| [eav.md](eav.md) | How values are stored and resolved — the column map, scope, the flat index |
| [attributes.md](attributes.md) | Creating attributes, families, groups, options, validation and swatches |

## The shape

```
attribute_families            a product type's whole form  (e.g. "Default")
  └── attribute_groups        a tab/section within it      (e.g. "General")
        └── attributes        a field                      (e.g. "name")
              └── attribute_options   for select, multiselect and checkbox
```

A product belongs to one family, and that family decides which attributes it
has. `AttributeFamily::custom_attributes()` and
`AttributeGroup::custom_attributes()` are the relations that walk it.

## The column map

An attribute's `type` decides which column of `product_attribute_values` holds
its value:

| type | column |
|---|---|
| `text`, `textarea`, `multiselect`, `checkbox`, `file`, `image` | `text_value` |
| `price` | `float_value` |
| `boolean` | `boolean_value` |
| `select` | `integer_value` |
| `date` | `date_value` |
| `datetime` | `datetime_value` |

This map lives on `Attribute::$attributeTypeFields`, and the model exposes the
resolved name as `$attribute->column_name`. Never guess the column — read it
from the attribute, or a `select` silently writes into `text_value` and reads
back as null.

`AttributeTypeEnum` is the authoritative list of types; `ValidationEnum`
(`numeric`, `email`, `decimal`, `url`, `regex`) and `SwatchTypeEnum`
(`dropdown`, `color`, `image`, `text`) cover the rest.

## Scope: the two flags that cause most bugs

Every attribute carries `value_per_locale` and `value_per_channel`. Together
they decide how many rows a single attribute has for one product, and which one
a read returns:

| `value_per_channel` | `value_per_locale` | Rows per product |
|---|---|---|
| false | false | 1 |
| false | true | one per locale |
| true | false | one per channel |
| true | true | one per channel per locale |

`Product::getCustomAttributeValue()` resolves the right row for the **requested**
channel and locale, falling back to the default channel and locale when the
requested one is empty. A value that "disappears" on a second locale is almost
always an attribute written without honouring these flags.

The table enforces this with a unique index on
`(channel, locale, attribute_id, product_id)`, so writing the wrong scope
combination is a constraint violation rather than a silent duplicate.

## Non-negotiables

- **Go through the repository.** `AttributeRepository`, `AttributeFamilyRepository`,
  `AttributeGroupRepository`, `AttributeOptionRepository` — never write
  `product_attribute_values` by hand.
- **Read the column from the attribute**, via `column_name` or
  `$attributeTypeFields`. A hard-coded column is a bug waiting for the first
  non-text attribute.
- **Honour `value_per_locale` and `value_per_channel` on every write**, not just
  on read. Writing one row for an attribute scoped per locale loses every other
  locale's value.
- **A `select`/`multiselect` value is an option id**, not the label. `select`
  stores one id in `integer_value`; `multiselect` stores comma-separated ids in
  `text_value`.
- **Option labels are translatable** — they live in
  `attribute_option_translations`, so a label added in one locale must be added
  in all 22.
- **Changing an attribute's `type` orphans its existing values**, because the
  new type reads a different column. Treat it as a data migration, not an edit.
- **Reindex after a change that affects listing.** Filterable and listing
  attributes are denormalised into `product_flat` by the flat indexer; until it
  runs, the grid and storefront show the old value.

**REQUIRED SUB-SKILL:** Use bagisto-change-verification before calling any change done.

