---
topic: customer-fields
title: Extra customer fields, and the customer data a type change destroys
summary: tkundenfeld via CustomerFieldsController — why one changed argument rewrites rows in two tables nobody named, and why cWawi is required and then discarded.
appliesTo:
  - customerfield.field.create
  - customerfield.field.update
  - customerfield.field.delete
derivedFrom:
  - includes/src/Router/Controller/Backend/CustomerFieldsController.php:26-177
  - includes/src/Backend/CustomerFields.php:22-225
  - includes/src/PlausiKundenfeld.php:16-65
  - includes/src/Router/Controller/Backend/AbstractBackendController.php:137-167
  - admin/templates/bootstrap/kundenfeld.tpl:135-375
---

## Domain model

Three tables, no foreign keys, one hand-written cascade.

- `tkundenfeld` (PK `kKundenfeld`) — the DEFINITION: `kSprache`, `cName` (customer-facing label),
  `cWawi` (JTL-Wawi attribute name), `cTyp`, `nSort`, `nPflicht`, `nEditierbar`. **Per language**:
  one logical field is two rows in a two-language shop, and nothing links them.
- `tkundenfeldwert` — the drop-down options of a `cTyp = 'auswahl'` field.
- `tkundenattribut` — **what customers actually typed**, one row per `(kKunde, kKundenfeld)`; that
  pair is UNIQUE and `NiceDB::insertRow()` swallows the duplicate-key error, returning `0`.

`cTyp` is `text | zahl | datum | auswahl`, and **the server validates it against nothing**
(`CustomerFieldsController.php:146`): an unknown value is stored verbatim and renders as a text box.

**Customer GROUPS are not here and not in the catalog at all** — no route, controller or
`Permissions::` constant for them in 5.x; they belong to JTL-Wawi and the REST API.

## The gate and the flush

Every write sits behind `Request::pInt('kundenfelder') === 1 && Form::validateToken()` (`:38`), so
`kundenfelder=1` is a mandatory const on all three entries; the invoker mints the token, so that
silent-`return` branch is unreachable. The branch is chosen by mere PRESENCE:
`isset($_POST['loeschen' | 'aktualisieren' | 'speichern'])` (`:51-56`). All three end at `:60` with
`flushTags([\CACHING_GROUP_OBJECT])` — hence `cacheFlushTags` and the flush under `undo.cannotUndo`
on every entry. A dry run rolls the rows back but **not** the flush.

Feedback is `alertService` only (`:106`-`:172`) — session-scoped, absent from the response body.
**`verify.ok` is the signal**, not `ok` and not the status.

## The language you cannot choose

`setLanguage()` (`:31`, `AbstractBackendController.php:137-167`) reads
`$_SESSION['editLanguageID']`, which the sandbox does not seed
(`BackendActionInvoker.php:254-266`), so the fallback at `:148-157` always runs: the language is
**always the shop's default** (`tsprache.cShopStandard = 'Y'`, else `1`). Deterministic, and a hard
limit: there is no language argument, so a non-default-language field can only be created or edited
in the backend. Create and update both assert
`kSprache = (SELECT kSprache FROM tsprache WHERE cShopStandard = 'Y')`.

## Reads first

1. `SELECT * FROM tkundenfeld ORDER BY kSprache, nSort` — the source of every id.
2. Before an update or delete: `SELECT COUNT(*) FROM tkundenattribut WHERE kKundenfeld IN (<ids>)`,
   plus `tkundenfeldwert` if the field is `auswahl` — the customer data at risk.
3. Before a delete: `SELECT COUNT(*) AS n FROM tkundenattribut ta LEFT JOIN tkundenfeld f ON
   f.kKundenfeld = ta.kKundenfeld WHERE f.kKundenfeld IS NULL` — the delete entry's own `verify:`.

## Creating a field

`kundenfelder=1` + `speichern` + `kKundenfeld=0` reaches `actionSave()` (`:136-177`), which builds
the row from seven POST keys (`:138-150`). All six caller-supplied values are mandatory TOGETHER
(`PlausiKundenfeld::doPlausi()`, `:21-38`: `cName`, `cWawi`, `cTyp` non-empty; `nSort`, `nPflicht`,
`nEdit` present) — a rejected save writes NOTHING and still answers 200 with only a session
`errorFillRequired`.

Two traps: the POST name `nEdit` becomes the column `nEditierbar` (`:149`), and `cName`/`cWawi` are
stored TRANSFORMED (`filterXSS()` strips tags and `\ " '`, `htmlspecialchars()` escapes `& < >`,
`:141-145`) — `&` comes back `&amp;`, so such a name will not equal what you sent. And `cWawi` is
your only chance at that column: `save()` unsets it on every update (`CustomerFields.php:170`).

`idResolution: maxIdDelta` is exact here because the create only INSERTs, so `verify:` can read the
new row back column by column and a plausi refusal shows up as `could not bind :kKundenfeld`.

**`auswahl` cannot be created here.** Its options are posted as `$_POST['cfValues'][{key}][cWert]` /
`[nSort]` (`kundenfeld.tpl:234`) — a NESTED array, which no catalog field name can spell — and
`PlausiKundenfeld.php:39-50` dereferences `cfValues` **unguarded** the moment `cTyp` is `auswahl`.
Create drop-downs in the backend; you can still delete one, or move one OUT of `auswahl`
(destructive — see below).

**A duplicate `cName` is refused.** `PlausiKundenfeld.php:51-61` looks the name up under
`$_SESSION['kSprache']`, which the invoker seeds with the shop default. You get status 200, alert
`errorCustomerFieldNameExists`, and nothing written — `verify.ok` is `false`.

## Editing a field

Same branch and dispatch key: `speichern` with `kKundenfeld > 0`.

**It is not a patch.** `actionSave()` rebuilds the object from the POST and `save()` writes it WHOLE
(`CustomerFields.php:172`): every column you did not send is written from `Request::pInt`/`pString`'s
default, so an omitted `nSort` is `0` and moves the field to the top. Hence all seven
`required: true` — including `cWawi`, which the plausi demands (`PlausiKundenfeld.php:24-26`) and
`save()` then unsets before the UPDATE (`:170`). Send the row's existing `cWawi` back; omitting it
costs the whole call. `kSprache` is unset on the same line and is not offered. If either is wrong,
delete the field and re-create it.

**A `cTyp` change is a cascade over rows nobody named** (`CustomerFields.php:174-204`), fired only
when the type actually DIFFERS from the stored one:

| from → to | what happens to customer data |
|---|---|
| `auswahl` → anything | every `tkundenfeldwert` row of the field is DELETED (`:176-179`) |
| → `zahl` | `SET cWert = CAST(CAST(cWert AS DOUBLE) AS CHAR)` over every `tkundenattribut` value of the field (`:181-188`) — `'ABC'` becomes `'0'` |
| → `datum` | `DATE_FORMAT(STR_TO_DATE(cWert, '%d.%m.%Y'), …)` (`:190-197`) — anything unparseable becomes NULL |
| → `text` | nothing (`:199-202`) |

Row counts are unbounded and unknown beforehand, and both conversions are lossy — only the journal's
pre-image restores them. Hence `Destructive` with a three-table journal scope. **If you are only
renaming, send the stored `cTyp` back** and the cascade stays dormant.

**An id from another language is not an error**: `save()` takes the update branch only for ids
loaded under `currentLanguageID` (`:165`), otherwise it falls through to the INSERT at `:206` with
the primary key already set. The second `verify:` clause catches that and rolls the call back.

Moving a field INTO `auswahl` is not offered either — the unguarded `cfValues` read fires first.

## Deleting fields

`kundenfelder=1` + `loeschen` + `kKundenfeld[]` reaches `actionDelete()` (`:101-120`), which reads
`$_POST['kKundenfeld']` **unguarded** (`:104`) — hence `required: true` on the list — and calls
`CustomerFields::delete()` per id: three separate statements, all `WHERE kKundenfeld = <id>`
(`CustomerFields.php:110-112`), against `tkundenattribut` (every customer's answer), then
`tkundenfeldwert` (the options), then `tkundenfeld`. Separate statements mean a PARTIAL cascade is
possible — a failure between them leaves a field whose values are already gone. No soft delete, no
FK cascade, no confirmation beyond a JS alert (`kundenfeld.tpl:391-392`) that never runs here, and
no language filter.

**A non-existent id is reported as SUCCESS**: `delete()` returns false only for id `0` (`:107`), and
a `DELETE` matching nothing returns `0`, which is `>= 0`. Read the ids back yourself.
`kKundenfeld` is a SCALAR on the update (`kundenfeld.tpl:141`) and an ARRAY here (`:313`).

**The delete's `verify:` is global.** An array argument cannot be bound into a read-back query
(`ActionInvocation:432-451`, `:713-720` — no `IN (…)` expansion), so the shipped clause is the
LITERAL orphan check of read 3, expecting `0`. It proves the STATE, **not** that this call caused
it, and it is not scoped to your ids: pre-existing orphans make it false, so a good delete is
reported `ok: false` and rolled back. Run read 3 first.

## Not in the catalog

- **The bulk re-sort** (`aktualisieren`, `:122-134`) reads the generated POST name `nSort_<id>`
  (`kundenfeld.tpl:335`), which no catalog field name can spell, and writes `0` into the sort of
  every field whose key is absent. Re-sort one at a time with `customerfield.field.update`.
- **`einstellungen`** (`:36-37`) writes dynamic config keys — the settings bridge's job.
- **`a=edit`** (`:40`, `:90-99`) only assigns Smarty variables the backend discards.

## The stale-singleton trap

`CustomerFields` is a static singleton keyed by language (`CustomerFields.php:22`, `:37-48`) caching
the field list for the life of the process. Normally invisible (each MCP request is a fresh
process), but against a stale instance an update takes the update branch for an id that is gone:
`UPDATE … WHERE kKundenfeld = <gone>` matches nothing, `>= 0` still holds, and the controller
reports success. Only `verify:` catches it.
