# Events & Observers (reacting to what CBX does)

> How to run your own code when something happens in CBX — an order is placed, a record is stored, a tax rate is computed, a customer registers — without modif…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/customization/events_and_observers. Last updated 2026-08-01.

---
How to **run your own code when something happens** in CBX — an order is placed, a record is stored,
a tax rate is computed, a customer registers — without modifying core. This is the **observer** mechanism:
you write a class with methods named after events, register it via a small DB record, and the framework
calls your methods alongside the built-in ones.

This is the **safest, most upgrade-resilient** way to inject behavior: it is **additive** (you add to a
dispatch list; you don't replace any class), so it never drifts out of sync the way a class override does.
Prefer it over overriding controllers/models whenever "react to X" or "augment X" describes your need.

Read `com_configbox_customization_overview.md` first. All paths are relative to the component root
`docroot/components/com_configbox/`. Source references are point-in-time (component **3.4.1**) — verify
against the code.

---

## 1. How observers work

The dispatcher is `KenedoObserver` (`external/kenedo/classes/KenedoObserver.php`). An **observer** is a
class whose **method names are event names**. When code fires an event, the dispatcher calls that method on
every registered observer that has it (`triggerEvent()`, `:54-89`):

```php
foreach (self::$observers as &$observer) {
    if (method_exists($observer, $eventName)) {
        $returns[get_class($observer)] = call_user_func_array(array($observer, $eventName), $parameters);
    }
}
```

So you don't subscribe to specific events — you just **implement the methods you care about** and ignore
the rest. Registration is **lazy**: observers are registered the first time any event fires
(`:56-62`), in this order:

1. **Custom observers with `after_system = 0`** (`registerCustomObservers(false)`)
2. **All built-in observers** (`observers/`)
3. **Custom observers with `after_system = 1`** (`registerCustomObservers(true)`)

That ordering is the lever for "run before vs. after the core observers" (§4).

---

## 2. Writing an observer

An observer is a plain class named **`Observer<Something>`**, with one method per event you want to handle.
The class file lives in `data/customization/custom_observers/`, and the class name is derived from the file
name: `registerObservers()`/`registerCustomObservers()` build it as `'Observer'.<basename-without-extension>`
(`:22`, `:41`). So a file `MyShipping.php` must declare `class ObserverMyShipping`.

```php
// data/customization/custom_observers/MyShipping.php
<?php
defined('CB_VALID_ENTRY') or die();

class ObserverMyShipping {

    // React: send a webhook when an order reaches a paid status.
    function onConfigBoxSetStatus($orderId, $status) {
        if ($status != ConfigboxOrderHelper::getStatusCodeForType('paid')) {
            return;
        }
        // … your side-effect (queue a webhook, write a log row, etc.)
    }
}
```

The method **signature must match the event's** (§5) — including the **`&` for by-reference** parameters,
or you won't see the caller's mutations (§3).

---

## 3. The two event styles — react, augment, compute

Events come in three flavors. Knowing which one you're handling tells you *how* to write the method.

### 3.1 React (fire-and-forget)

The dispatcher ignores your return value; you just do a side-effect. Example call site
(`KenedoModel.php:374`):

```php
KenedoObserver::triggerEvent('onAfterStoreRecord', array($this->getModelName(), $data));
```

```php
function onAfterStoreRecord($modelName, $data) {
    if ($modelName != 'adminorders') return;   // scope to the model you care about
    // … react …
}
```

### 3.2 Augment (by-reference)

The caller passes a value **by reference** and reads it back after the event. Your method **mutates the
argument in place** — it must declare the parameter with `&`. Example call site (`onConfigBoxAddToCart`,
fired with `array(&$cartDetails)`):

```php
KenedoObserver::triggerEvent('onConfigBoxAddToCart', array(&$cartDetails));
```

```php
function onConfigBoxAddToCart(&$cartDetails) {   // ← the & is REQUIRED to affect the caller
    $cartDetails->myFlag = true;
}
```

> **Forgetting the `&` is the #1 observer bug.** Without it you get a copy; your changes vanish. Match the
> built-in signature exactly (`onConfigBoxAddToCart(&$cartDetails)`, `onConfigBoxGetStatus(&$cartDetails)`,
> `onConfigboxGetTaxRate(&$taxRate, $taxRateId, $userId = NULL)`).

### 3.3 Compute (return value, last-wins)

When the caller passes `$returnLast = true` to `triggerEvent()`, it uses the **last** registered observer's
return value (`:78-85`). Example call site (`onConfigboxGetPaymentOptions`):

```php
$paymentOptions = KenedoObserver::triggerEvent('onConfigboxGetPaymentOptions', array($cartDetails->userInfo, $baseTotal), true);
```

```php
function onConfigboxGetPaymentOptions($customerData, $baseTotal) {
    return $myPaymentOptions;   // your return can become THE result — see the ordering caveat
}
```

> **"Last wins" + registration order interact.** With `$returnLast`, the value used is from whichever
> observer ran **last**. Built-ins run in the middle; your `after_system = 1` observers run **after** them
> (§1), so an `after_system = 1` observer's return overrides the built-in's. If you need to *supersede* a
> built-in computed result, register `after_system = 1`; if you need to provide a *fallback* the built-in
> can override, register `after_system = 0`. (For non-`returnLast` events all returns are collected but the
> caller typically ignores them.)

---

## 4. Registering your observer (the DB connector)

Unlike most customization mechanisms, dropping the file in place is **not enough** — custom observers are
gated by a **connector record** in the `#__configbox_connectors` table. `registerCustomObservers()` only
loads files named by a *published* connector row, and uses its `after_system` flag for ordering
(`KenedoObserver.php:29-48`); the rows are read by `ConfigboxCacheHelper::getCustomConnectors()`
(`helpers/cache.php:1639-1660`):

```sql
SELECT * FROM `#__configbox_connectors` WHERE `published` = '1' ORDER BY `ordering`
```

The table (`helpers/updates/2.6.0.php:1312-1320`):

| Column | Meaning |
|---|---|
| `name` | a label for the admin list |
| `file` | the file name in `data/customization/custom_observers/` (e.g. `MyShipping.php`) → class `ObserverMyShipping` |
| `after_system` | `1` = register **after** built-ins (default); `0` = **before** built-ins (§1, §3.3) |
| `published` | `1` to enable (the query filters on this) |
| `ordering` | sort order among connectors |

You add the row through the admin UI — the **Connectors** screen (`controllers/adminconnectors.php`,
`models/adminconnectors.php`, `views/adminconnectors`) — or via a customization migration that inserts the
row. Either way:

> **Two-part deployment.** (1) the file under `custom_observers/`, and (2) a published connector row
> pointing at it. Miss the row and your observer silently never runs. The connector list is **cached**
> (`getCustomConnectors()` memoizes + persists), so after inserting a row directly in the DB, clear the
> CBX cache.

Delivering the row via migration (so it ships with your code) looks like:

```php
// data/customization/updates/<version>.php
defined('CB_VALID_ENTRY') or die();
$db = KenedoPlatform::getDb();
$exists = $db->getObject("SELECT id FROM `#__configbox_connectors` WHERE `file` = ".$db->getQuoted('MyShipping.php'));
if (!$exists) {
    $db->query("INSERT INTO `#__configbox_connectors` (`name`,`ordering`,`published`,`after_system`,`file`)
                VALUES (".$db->getQuoted('My shipping webhook').", 100, 1, 1, ".$db->getQuoted('MyShipping.php').")");
}
```

(Follow the existing escaping idioms — `getQuoted()` quotes values; see `technical/com_configbox_migrations.md`.)

---

## 5. The event catalog

These are the events fired via `KenedoObserver::triggerEvent()` in component 3.4.1. **Style**: R = react,
A = augment (by-reference — note the `&`), C = compute (`returnLast`). Match the signature exactly.

### Lifecycle / system

| Event | Signature | Style | Fired when |
|---|---|---|---|
| `onConfigboxInitialized` | `()` | R | CBX finished booting (`observers/System.php:6` loads the system overrides here). |
| `onBeforeRender` | `()` | R | before output rendering. |
| `onAfterStoreRecord` | `($modelName, $data)` | R | any model record stored (`KenedoModel.php:374`). |
| `onAfterCopyRecord` | `($modelName, $newData)` | R | any model record copied (`KenedoModel.php:603`). |
| `onAfterDeleteRecord` | `($modelName, $id)` | R | any model record deleted (`KenedoModel.php:1457`). |

### Cart / checkout / order

| Event | Signature | Style | Purpose |
|---|---|---|---|
| `onConfigBoxAddToCart` | `(&$cartDetails)` | A | augment a configuration as it's added to the cart. |
| `onConfigBoxCheckout` | `(…)` | R/A | at checkout. |
| `onConfigBoxCartProcessingBeforeDiscounts` | `(…)` | A | during cart totals, before discounts. |
| `onConfigBoxCartProcessingAfterPositions` | `(…)` | A | during cart totals, after positions. |
| `onConfigBoxLoadOrderRecordBeforeDiscounts` | `(…)` | A | loading an order, before discounts. |
| `onConfigBoxAfterLoadOrderRecord` | `(…)` | A | after an order record is loaded. |
| `onConfigBoxGetCbOrderId` | `(&$cartDetails)` | A | resolve/assign the CB order id. |
| `onConfigBoxUpdateUserInfo` | `(…)` | A | user info on a cart/order changes. |

### Order status & permissions

| Event | Signature | Style | Purpose |
|---|---|---|---|
| `onConfigBoxSetStatus` | `($orderId, $status)` | R | an order's status was set — **the main "order happened" hook** (built-ins use it for GA, notifications, tracking). |
| `onConfigBoxGetStatus` | `(&$cartDetails)` | A | augment status info on load. |
| `onConfigBoxGetStatusCodes` | `()` | C | contribute the set of status codes. |
| `onConfigBoxGetStatusCodeForType` | `($type)` | C | map a status *type* (`'paid'`, `'checked out'`, …) to a code. |
| `onConfigBoxGetActionPermission` | `($action, $cartDetails)` | C | allow/deny an action on an order. |

### Pricing / tax / shipping / payment

| Event | Signature | Style | Purpose |
|---|---|---|---|
| `onConfigboxGetTaxRate` | `(&$taxRate, $taxRateId, $userId = NULL)` | A | override the tax rate. |
| `onConfigboxGetDeliveryOptions` | `($cartId, $customerData, $weight, $maxDimensions, $cheapestOnly = false)` | C | provide shipping options. |
| `onConfigboxGetPaymentOptions` | `($customerData, $baseTotal)` | C | provide payment options. |

### Customer / auth

| Event | Signature | Style | Purpose |
|---|---|---|---|
| `onCustomerRegistration` | `($customerData)` | R | a customer registered. |
| `onUserLogin` | `(…)` | R | login succeeded. |
| `onUserLoginFailure` | `(…)` | R | login failed. |
| `onContentPrepare` | `(…)` | A | content-preparation hook (Joomla-style). |

> **Verify the exact signature against the trigger site** for your version before relying on it — params
> evolve. Grep the codebase: `grep -rn "triggerEvent('<EventName>'" .` shows the call site (and whether a
> param is passed `by &reference` and whether `true` is passed for `returnLast`). The catalog above is a
> map; the call site is the source of truth.

> ⚠️ **Not events:** `onOrderPlaced`, `onPaymentReceived`, `onQuotationRequested`,
> `onOrderAddressSupplied` are **internal convenience methods on `ObserverTracking`** (called from its own
> `onConfigBoxSetStatus`), **not** dispatched through `triggerEvent`. Implementing them on *your* observer
> does nothing. To react to those moments, handle `onConfigBoxSetStatus` and branch on the status code.

---

## 6. Worked example — webhook on a placed order

React when an order is placed (status set to "checked out"), additive and side-effect-only.

```php
// data/customization/custom_observers/OrderWebhook.php   (→ class ObserverOrderWebhook)
<?php
defined('CB_VALID_ENTRY') or die();

class ObserverOrderWebhook {

    function onConfigBoxSetStatus($orderId, $status) {

        // Only fire for the "checked out" status.
        $checkedOut = KenedoObserver::triggerEvent('onConfigBoxGetStatusCodeForType', array('checked out'), true);
        if ($status != $checkedOut) {
            return;
        }

        // Load order data and post it somewhere. Keep it fast / fail-soft — don't break checkout.
        try {
            $order = ConfigboxOrderHelper::getOrderRecord($orderId);
            // … send $order to your endpoint …
        }
        catch (Exception $e) {
            KLog::log('OrderWebhook failed for order '.$orderId.': '.$e->getMessage(), 'error');
        }
    }
}
```

Then register it (admin Connectors screen, or a migration):

```sql
INSERT INTO `#__configbox_connectors` (`name`,`ordering`,`published`,`after_system`,`file`)
VALUES ('Order webhook', 100, 1, 1, 'OrderWebhook.php');
```

Clear the cache, place a test order, confirm the webhook fired.

---

## 7. Deployment checklist

```
data/customization/
  custom_observers/
    <File>.php                 ← class Observer<File> with methods named after events
  updates/
    <version>.php              ← (optional) migration inserting the #__configbox_connectors row
```

1. **Write the observer** `data/customization/custom_observers/<File>.php` → class `Observer<File>`,
   implementing only the event methods you need.
2. **Match the event signature exactly**, including `&` on by-reference params (§3.2/§5).
3. **Register a connector row** (`#__configbox_connectors`) — admin Connectors screen or a migration. Set
   `published = 1` and choose `after_system` (1 = after built-ins, 0 = before) per §3.3/§4.
4. **Clear the CBX cache** (the connector list is cached).
5. **Keep handlers fast and fail-soft** — observers run inside live flows (checkout, save). Catch your own
   exceptions; don't let a webhook break an order.
6. **Verify manually** — trigger the real event and confirm your method ran (and, for augment events, that
   your mutation stuck). CBX has **zero automated tests**.

---

## 8. Conventions & gotchas

- **Two-part deployment**: the file *and* a published connector row. No row → never runs (§4).
- **`Observer<File>` naming** — class name = `Observer` + the file's basename (`:22`,`:41`).
- **`&` on by-reference params** or your augmentation is lost (§3.2). Copy the built-in signature.
- **`after_system` controls ordering** and therefore "last wins" for `returnLast` compute events (§3.3).
- **The connector list is cached** — clear cache after DB changes.
- **`onConfigBoxSetStatus` is the main order hook**; the `onOrderPlaced`-style names are *not* events (§5).
- **Fail-soft and fast** — observers run in checkout/save paths; an unhandled exception can break the flow.
- **Verify the signature at the call site** (`grep triggerEvent('<Event>'`) — params and `returnLast` vary
  by version.
- **Escape/quote in migrations and DB writes** — `getQuoted()`; follow neighboring code.

---

## See also

- `com_configbox_customization_overview.md` — the extension-point map (observers are the **registration**
  mechanism).
- `com_configbox_overriding_controllers_and_models.md` — when you must *change* behavior rather than react
  to it; explains why observers/property-injection are preferred over class replacement.
- `com_configbox_payment_connectors.md` — payment integrations (a related connector mechanism).
- `technical/com_configbox_migrations.md` — delivering the `#__configbox_connectors` row via migration.
- `external/kenedo/classes/KenedoObserver.php` — the dispatcher: registration order (`:29-62`), dispatch +
  `returnLast` (`:54-89`).
- `observers/` — built-in observers (Orders, Notifications, GoogleAnalytics, Tracking, …) as worked
  references.
