# Looking up assignments: ConfigboxAssignmentsHelper

> Applies to: any customization that reads ConfigboxCacheHelper::getAssignments. Status: getAssignments still works and is not going away. This is a better way…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/assignments-lookups. Last updated 2026-07-26.

---
**Applies to:** any customization that reads `ConfigboxCacheHelper::getAssignments()`.
**Status:** `getAssignments()` still works and is not going away. This is a better way to ask the same
questions, and it removes a class of bug you have probably already hit.

---

## The one-line version

```php
// before
$ass = ConfigboxCacheHelper::getAssignments();
if (!isset($ass['question_to_product'][$questionId])) { /* handle absent */ }
$productId = $ass['question_to_product'][$questionId];

// after
$productId = ConfigboxAssignmentsHelper::getProductIdForQuestion($questionId);
if ($productId === null) { /* handle absent */ }
```

Every relation the array had now has a method. Nothing in core reads the array any more.

---

## Why bother — three reasons, in order of how likely they are to bite you

### 1. The undefined-key trap is gone

The assignments map holds only **published** questions, on published pages of published products. An
unpublished or half-created question is *legitimately* absent, so `$ass['question_to_product'][$id]`
raises an undefined-key warning — and on any install running warnings-as-exceptions (Magento's
developer mode does) that is a **fatal 500**.

That is why the cheat sheet tells you to guard every single read. With the helper there is nothing to
guard: a point lookup returns `null` and a collection returns `array()`. You still *handle* the absent
case, but forgetting to no longer takes the page down.

### 2. It does not load the whole catalog to answer one question

`getAssignments()` builds 24 nested arrays covering every product, page, question, answer,
calculation, list and shipping zone in the store, caches them as one entry, and every caller loads
all of it. On a dev install that is 24 KB and 0.2 ms, which is why it never looked like a problem.
Measured against catalog size:

| answers in store | cache file | load time | memory |
|---|---|---|---|
| 64 | 24 KB | 0.23 ms | 2 MB |
| 5,000 | 930 KB | 8.9 ms | 12 MB |
| 20,000 | 4 MB | 35 ms | 44 MB |
| 60,000 | 12 MB | 119 ms | 120 MB |

Paid per request, in every PHP worker, to answer "which product owns question 4711".

The helper reads what you asked for. It also **slice-loads**: the first question you ask about pulls
that product's whole question↔page↔product map in one query, so the next few hundred lookups for
sibling questions cost nothing. A price run over 200 answers is one query, not 200 — and its cost
tracks the size of the *product*, not the store.

### 3. Nothing for you to invalidate

The helper memoises per request and stops there — no file cache, no APCu. These are single indexed
reads against real foreign keys; the caching cost more than it saved. One less thing that can be
stale, and one less thing whose cache clear can collide with someone else's.

Within a request the memo **invalidates itself**. `KenedoModel::store()`, `delete()`, `copy()`,
`publish()` and `storeOrdering()` drop it whenever the table they wrote is one the relations are
derived from, so code that creates a question and then asks what belongs to the product sees the
question it just created. `publish()` matters most: "belongs to" here means "belongs to *and* the
whole chain is published", so publishing changes the answer without touching a single foreign key.

The one case left to you is **raw SQL**. If you write one of those tables without going through its
model, call `ConfigboxAssignmentsHelper::forget()` afterwards — the same thing `forgetRecords()` used
to be for.

---

## The full mapping

Old array key → new method. `id => id` means the shape is unchanged, so `isset($ids[$x])`,
`array_keys()` and `foreach` keep working exactly as before.

| Old key | New method | Returns |
|---|---|---|
| `question_to_product` | `getProductIdForQuestion($questionId)` | `int|null` |
| `question_to_page` | `getPageIdForQuestion($questionId)` | `int|null` |
| `product_to_question` | `getProductQuestionIds($productId)` | `id => id` |
| `page_to_question` | `getPageQuestionIds($pageId)` | `id => id` |
| `product_to_page` | `getProductPageIds($productId)` | `id => id` |
| `page_to_product` | `getProductIdForPage($pageId)` | `int|null` |
| `answer_to_question` | `getQuestionIdForAnswer($answerId)` | `int|null` |
| `answer_to_product` | `getProductIdForAnswer($answerId)` | `int|null` |
| `answer_to_page` | `getPageIdForAnswer($answerId)` | `int|null` |
| `question_to_answer` | `getQuestionAnswerIds($questionId)` | `id => id` |
| `product_to_answer` | `getProductAnswerIds($productId)` | `id => id` |
| `page_to_answer` | `getPageAnswerIds($pageId)` | `id => id` |
| `product_to_calculation` | `getProductCalculationIds($productId)` | `id => id` |
| `calculation_to_product` | `getProductIdForCalculation($calculationId)` | `int|null` |
| `list_to_product` | `getListProductIds($listId)` | `id => id` |
| `product_to_list` | `getProductListIds($productId)` | `id => id` |
| `country_to_zone` | `getZoneIdsForCountryId($countryId)` | `id => id` |
| `zone_to_country` | `getZoneCountryIds($zoneId)` | `id => id` |
| `zone_to_shippingmethod` | `getZoneShippingMethodIds($zoneId)` | `id => id`, cheapest first |
| `shippingmethod_to_zone` | `getZoneIdForShippingMethod($id)` | `int|null` ⚠️ |
| `city_to_county` | `getCountyIdForCity($cityId)` | `int|null` |
| `city_to_state` | `getStateIdForCity($cityId)` | `int|null` |
| `city_to_country` | `getCountryIdForCity($cityId)` | `int|null` ⚠️ |
| `county_to_state` | `getStateIdForCounty($countyId)` | `int|null` |
| — | `getCountryIdForCounty($countyId)` | `int|null` — **new**, see below |

Legacy `element_*` aliases (`element_to_product`, `page_to_element`, …) are the CB3 spelling of the
`question_*` keys. Migrate to the method for the **question** relation; there is no element method and
there will not be one. See [element-question-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename).

### ⚠️ Two shapes deliberately differ

**`getZoneIdForShippingMethod()` returns a scalar**, where `shippingmethod_to_zone[$id]` was a
one-element array. `zone_id` is one column on one row; the array only existed because the map was
built by inverting the zone map. If you were looping it, take the value directly.

**`city_to_country` was broken, and the fix adds a key.** The old build wrote each *county's* country
into `city_to_country` keyed by **county id**. Cities and counties are separate tables with separate
auto-increments, so their ids overlap: looking up city 42's country could return the country of county
42 instead — silently, and only for the ids that happened to collide.

`getAssignments()` now writes those into their own **`county_to_country`** map, and `city_to_country`
holds only cities. If your code compensated for the old behaviour, that compensation is now wrong. Use
`getCountryIdForCity()` and `getCountryIdForCounty()` and delete the workaround.

---

## Migrating your code

The transform is mechanical, and the guards usually disappear with it, because the methods already
return a safe empty value:

```php
// guarded ternary  ->  just the call
$ids = !empty($ass['product_to_question'][$pid]) ? $ass['product_to_question'][$pid] : array();
$ids = ConfigboxAssignmentsHelper::getProductQuestionIds($pid);

// null coalesce  ->  just the call
$ids = $ass['page_to_question'][$pageId] ?? [];
$ids = ConfigboxAssignmentsHelper::getPageQuestionIds($pageId);

// isset() used as "does this exist"  ->  compare to null
if (isset($ass['question_to_product'][$qid])) { … }
if (ConfigboxAssignmentsHelper::getProductIdForQuestion($qid) !== null) { … }

// a "default to 0" read  ->  keep the 0 explicitly
$productId = !empty($ass['page_to_product'][$pageId]) ? $ass['page_to_product'][$pageId] : 0;
$productId = ConfigboxAssignmentsHelper::getProductIdForPage($pageId) ?: 0;
```

**One trap when you do this by hand.** The `$ass = getAssignments();` line and the reads that use it
are often far apart — a different branch, fifty lines down. Delete the fetch only after grepping the
whole function for `$ass` / `$assignments`; core's own migration orphaned a reference twice by
removing it too eagerly.

### Grep pack

```bash
grep -rn "getAssignments()" data/customization/
grep -rnE "\$(ass|assignments)\['[a-z_]+_to_[a-z_]+'\]" data/customization/
```

Clean when both return nothing (or only the deliberate `getAssignments()` calls you decided to keep).

---

## What has NOT changed

- `getAssignments()` itself: same array, same 24 keys, same `element_*` aliases, still cached. It is
  part of the customization contract and core still builds it on demand.
- The **published-chain rule**. "Belongs to" means "belongs to, *and* the whole chain is published" —
  questions and answers require question + page + product published; pages require only their own
  flag. The helper mirrors this exactly, including that inconsistency. An unpublished question has no
  answers here, not "its published answers".
- Ordering. Pages come in configurator order, answers in display order, shipping methods cheapest
  first — same as the maps they replace.

Core's migration was verified by comparing every method against the live array for every id in the
catalog: 865 comparisons, zero differences. If you find one, that is a bug worth reporting.

---

## See also

- [CHEATSHEET.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/CHEATSHEET) — the old → new identifier lookup, including the assignment keys
- [element-question-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename) — the `element_*` → `question_*` rename
- `technical/com_configbox_kenedo_model.md` — models and their property-driven CRUD
