# Errors are exceptions now — what customization code has to change

> CBX used to report a refusal by returning false and parking a sentence in setError. That protocol is being retired in favour of exceptions, and the change re…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/exceptions-and-validation. Last updated 2026-08-01.

---
CBX used to report a refusal by returning `false` and parking a sentence in `setError()`.
That protocol is being retired in favour of exceptions, and the change reaches customization code:
**a custom property type or model that still returns `false` keeps working, but a custom one that
throws nothing and reports nothing will silently accept bad data.**

If you maintain anything under `data/customization/`, read the first section. The rest explains why.

---

## 1. What you have to change

### A custom property type's `check()`

```php
// Before
function check($data) {
    if ($somethingWrong) {
        $this->setError(KText::_('That value is not allowed.'));
        return false;
    }
    return true;
}

// After
function check($data) {
    if ($somethingWrong) {
        throw KenedoValidationException::forProperty($this, KText::_('That value is not allowed.'));
    }
    return true;
}
```

`forProperty()` attaches your property's name and label to the message, which is what lets a form
highlight the field instead of printing a sentence and leaving the operator to hunt for it.

**`setError()` no longer exists** — on `KenedoProperty` or on `KenedoModel` — so the "before" above
is now a fatal error rather than a deprecation. Returning `false` from `check()` is still read as a
refusal, but it can no longer carry a reason: `validateData()` turns it into "Field X reported
failed validation but gives no error message", which is all anyone could have salvaged from it.

### Code that calls `validateData()` or `store()`

```php
// Before
if ($model->validateData($data) === false) {
    $errors = $model->getErrors();
    // …
}

// After
try {
    $model->validateData($data);
}
catch (KenedoValidationException $e) {
    foreach ($e->getIssues() as $issue) {
        // $issue['field'], $issue['label'], $issue['message']
    }
}
```

`validateData()` returns `true` on success and **throws** on failure. It never returns `false`.

**This is the change most likely to bite you, and it does so quietly.** The same is true of
`store()`, `copy()`, `delete()` and `publish()`: none of them returns `false` any more. So a caller
that still reads

```php
if ($model->store($data) === false) {   // never true - the branch is dead
    // ...report the failure
}
```

no longer reports anything, and **a failed save reads as a successful one**. Grep your
customizations for `=== false` against those five methods; each one is a `try`/`catch` now.

### Catching

```php
try {
    // …
}
catch (KenedoValidationException $e) {   // the caller can fix it — show it on the form
}
catch (KenedoSystemException $e) {       // the caller cannot — log it, apologise
}
catch (KenedoException $e) {             // either of the above
}
```

---

## 2. Why

### `false` cannot say which kind of problem it is

A failed save means either *"you left the title empty"* or *"the database is gone"*. Those need
opposite handling — one belongs on the form, the other in a log with an apology — and `false` says
neither. Every layer guessed, and the guess was usually "system error", because that is the safe
thing to assume when you do not know.

### The information died on the way up

Copying recurses into child entities. Each level caught, flattened the reason to a string, and
returned `false`, so by the time a controller saw it the reason was gone. A real example from this
work: refusing to copy a currency because its code must stay unique produced

> A system error occurred during copying. Please notify your service provider.

Nothing was broken, and the operator could have fixed it in seconds. An exception carries its reason
through the recursion without any layer having to co-operate.

### Ignoring a refusal was too easy

`$property->check($data);` on its own is valid PHP that silently discards the answer. A missed check
is invisible until bad data is already stored. A thrown exception cannot be ignored by accident.

---

## 3. The exception types

| Class | Means | Answered with |
|---|---|---|
| `KenedoValidationException` | The data was refused; the caller can fix it | **422**, with per-field issues |
| `KenedoSystemException` | Something failed that the caller cannot fix | **500**, detail to the log only |
| `KenedoException` | Base of both | — |

`KenedoValidationException` carries **issues**, not just a message: each has `field`, `label` and
`message`. A save reports every bad field at once rather than stopping at the first, so an operator
does not fix one, retry, and discover the next.

Validation failures are deliberately **not logged as errors**. A shop owner mistyping a value is not
an incident, and logging it as one buries the incidents that matter.

### One more code, for a refusal that is not about a field

`KenedoProperty::ERROR_COPY_NOT_POSSIBLE` marks a copy that cannot be performed at all — a unique
value with no rule for making a copy distinct. Controllers answer it with **409** and the reason,
rather than 500 and an apology.

---

### Where it happened, as well as what

A refusal raised deep in a recursion needs to say WHERE. Copying a product copies its pages, and each
page its questions - so "Field Title cannot be empty" can be about any one of forty child records the
operator never named.

Every `KenedoException` therefore collects a **context trail** as it travels: each level that catches
and rethrows adds itself, and the controller puts the result in `meta.failedAt`, outermost first:

```json
"meta": {
  "logIdentifier": "wkagqslp3q",
  "failedAt": ["copy of adminproducts ID 21301", "copy of adminpages ID 20640", "copy of adminquestions ID 24595"]
}
```

The message and the per-field issues are untouched - the trail is context about the journey, not a
replacement for the reason. Flattening one into the other is the mistake the old protocol made every
time.

Use `$e->addContext('what this level was doing')` when you catch and rethrow, and
`$e->getContextualMessage()` when you want the whole thing as one sentence.

**If you wrap rather than rethrow**, carry the trail across - `KenedoSystemException::fromException()`
does. A new exception object starts with an empty trail, so a wrapping level that forgets produces a
trail naming every level ABOVE the failure and not the failure itself.

## 4. Status

**Complete.** `setError()`, `setErrors()`, `getError()`, `getErrors()` and `resetErrors()` are gone
from `KenedoModel` and `KenedoProperty`, and every call site in the component has been converted:
the Kenedo core, all ten stock property types, all fourteen models that had them, and the
controllers and helpers that used to read them. The compatibility shim in `validateData()` is
removed. There is only one style in stock code now.

`KenedoSystemException` is genuinely thrown — previously it existed but nothing raised it, so every
real failure still travelled as `false`. Build one with `KenedoSystemException::logged($detail)`, or
`::fromException($e, $context)` when wrapping something you caught: both write the detail to the
error log and hand back an exception carrying only the log identifier, which controllers put in the
response's `meta.logIdentifier`. **Do not put the detail in the exception message** — a 500 may be
read by a shop's customer.

`KenedoModelLight` is **deprecated**: it existed only to hand a model the four-method error bag, and
that bag is gone. It is now an empty subclass of `KenedoModel` so customization code naming it still
resolves — but if you extend it, note that `KenedoModel::__construct()` records the component and
refuses an empty one, where `KenedoModelLight` had no constructor at all. An override that does not
call `parent::__construct($component)` will now throw.

Not part of this and deliberately unchanged: `ConfigboxJsonResponse::setErrors()` (a response DTO),
the `KenedoPlatform` implementations' own error bag, and `ConfigboxUserHelper`.

@see docs/technical/com_configbox_kenedo_model.md
