Skip to content

Powered by Grav CMS + Helios Theme

Custom fields and the settings store

Two ways to store extra data: typed fields attached to a product, an order or a customer, and untyped scoped settings that belong to a plugin. This page is for add-on developers, and for merchants who want a field the built-in ones do not cover.

Before you begin

  • Making or editing a field in the admin needs kahunacart.settings.
  • Declaring a field from code needs a plugin of your own to subscribe from.

Which one you want

Custom field Settings store
Attached to One row: a product, a variant, an order, an order line, a customer, an address A plugin, not a row
Typed Yes: text, textarea, integer, boolean, select, json No, values are stored as JSON
Reaches Twig Yes, unless internal No
Sorts and filters the admin list Yes, when you say so No
Adds database columns No. Two shipped tables hold every field on every store No. One namespaced row per key
Per admin user No Yes, with scope: user
Registered through onKahunaCartRegisterCustomFields onKahunaCartRegisterSettingsNamespaces

A warranty period on a product is a custom field. A warranty add-on's API key is a settings-store key.

A checkout field is a third thing: something collected from the shopper in the checkout form.

Custom fields

Visibility

Visibility decides where a value travels.

Visibility Twig The admin Writable through the API
public Yes Yes Yes
readonly Yes Yes No. A write is refused with a 422 naming the field
internal Never Yes Yes

An internal field never reaches a template. kahunacart_custom_fields() filters it out, and the custom_fields variable on the product page does not carry it. CustomFieldValues::forTemplate() enforces this.

The storefront JSON API carries no custom fields. An add-on that wants its own field in a JSON response adds it through onKahunaCartExtendStorefrontSchema. See Storefront JSON API → Add your own keys to a response.

Important

internal keeps a value out of templates. It is not a permission: an admin user with kahunacart.orders.view can read internal values through GET /kahunacart/custom-fields/values/{entity}/{entityId}. Do not put a secret in one. Put it in the settings store behind a permission of your own.

Types and their validation

Type Stored in validate keys it understands
text value_text max_length (default 190), pattern (a regular expression, no delimiters)
textarea value_json max_length (unlimited by default), pattern
integer value_int min, max
boolean value_int none
select value_text options, required, a list of values or a map of value to label
json value_json none

text and select sort and filter as strings on the indexed value_text column. integer and boolean sort on value_int, also indexed. A json or textarea field cannot be sortable or filterable.

A value that fails its validation is refused with a sentence naming the field, and nothing on the row is written.

Declare a field from an add-on

Subscribe to onKahunaCartRegisterCustomFields and declare onto the registry. A declared field is written into kahunacart_custom_fields on the first request that reads it, so it persists for merchants who later switch the add-on off.

PHP
public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterCustomFields' => ['onKahunaCartRegisterCustomFields', 0]];
}

public function onKahunaCartRegisterCustomFields(Event $event): void
{
    $event['registry']->declare([
        'entity' => 'product',
        'name' => 'warranty_months',
        'type' => 'integer',
        'label_key' => 'PLUGIN_KAHUNACART_WARRANTY.MONTHS',
        'visibility' => 'public',
        'sortable' => true,
        'filterable' => true,
        'default' => 12,
        'validate' => ['min' => 0, 'max' => 120],
        'position' => 10,
    ], 'kahunacart-warranty');
}
Key Required Notes
entity yes product, variant, order, order_item, customer, address, or one you registered
name yes [a-z][a-z0-9_]*, unique on the entity. First declaration of a name wins
type no Default text. It cannot change after values exist
label_key no A translation key or a plain string. Falls back to the name
help_key no The sentence under the control
visibility no Default public
sortable, filterable no Default false. Both are backed by an index, so saying yes costs nothing at query time
required no Default false
default no The value a row starts with
validate no The keys the type understands, from the table above
position no The order on the admin form. Default 0

The second argument is your plugin's slug. It is recorded on the definition so the admin can say whose field it is.

Register an entity of your own

A field can hang off a table your own add-on ships. Register the entity first, in the same listener, before declaring fields on it.

PHP
$event['registry']->registerEntity('subscription', 'Subscription', 'kahunacart_subscriptions', 'kahunacart-subscriptions');

Read values in Twig

kahunacart_custom_fields(entity, id) returns a CustomFieldValues object carrying every value a template may see. Iterate it, index it, or call a typed accessor.

TWIG
{% for name, value in kahunacart_custom_fields('product', product.id) %}
    <div>{{ name }}: {{ value }}</div>
{% endfor %}

{{ kahunacart_custom_fields('product', product.id).int('warranty_months') }}
{{ kahunacart_custom_fields('order', order.id).text('gift_message') }}
Method Answers
get(name) The value, or the field's default when nothing is stored
text(name) The value as a string, '' when unset
int(name) The value as an integer, 0 when unset
bool(name) The value as a boolean
json(name) The value as an array
has(name) Whether the field is declared and visible here
isSet(name) Whether a value is stored, as opposed to defaulted

The product page already carries custom_fields for the product being shown, and templates/partials/kahunacart-custom-fields.html.twig prints them as a definition list. Override that partial in your theme to change how they look. The Kahuna theme (1.0.8) shows them after the specification table, where the plugin's own product page puts them. See Twig functions and filters.

Sort and filter the admin lists

Set sortable or filterable on a field and it becomes a column of the list for its entity: GET /kahunacart/products for a product field, GET /kahunacart/orders for an order field, GET /kahunacart/customers for a customer field. Orders and customers gained this in 1.3.1; before that only the product list answered.

  • ?sort=cf:warranty_months orders by it, ?sort=cf:warranty_months:desc the other way. Rows with no value come last either way.
  • ?cf[warranty_months]=24 narrows to the rows holding that value. Several cf[…] parameters are combined with AND.

A text search wins over both, because the search engines order by rank. The total in the answer counts the narrowed set, not the whole table. Each list keeps its own rules while it narrows: the order list still leaves out carts and still reads newest completed first, and the customer list still carries each customer's order count and lifetime total. A name that is not a filterable field on that entity is refused with a 422 naming it.

So an add-on that marks an order as needing a call to a fulfilment service can ask for its work queue in one request: GET /kahunacart/orders?cf[printful_status]=pending.

Make a field in the admin

  1. In the admin sidebar, click KahunaCart, then Extensions. The Custom fields panel lists every field on the store, with the plugin that declared it or This store for one made here.
  2. Click New field.
  3. Choose the entity, then fill in the name, type, label and visibility.
  4. Tick Sortable in admin lists or Filterable in admin lists if the list should order or narrow by it.
  5. Click Create field.

The field now appears on the detail form for every row of that entity, in the Custom fields panel beside the record.

Editing a field changes its label, help, visibility, flags, default, validation and position. The entity, the name and the type are fixed once the field exists, because stored values were written against them.

A field an add-on declared can be edited but not deleted, because it comes back on the next request. Delete is offered only for a field made on this store.

Caution

Deleting a custom field deletes every value stored under it, on every row of its entity. The answer says how many values went with it, and there is no undo.

The demo catalog declares five

bin/plugin kahunacart seed writes five fields across three entities: warranty_months (integer, public, sortable and filterable), supplier_code (text, internal), care_instructions (textarea, public), gift_message on an order, and vat_number (text, readonly) on a customer. Every seeded product has the first two, the clothing has the third, and seed --account writes the VAT number on the demo customer and a gift message on its delivered order.

They are declared with no owning plugin, like a field typed into the admin screen, so they can be deleted and seed --purge can take them back out. Purge only removes one when no value of yours is still stored under it. See CLI → seed and Demo store.

The endpoints

Method Path Permission
GET /kahunacart/custom-fields kahunacart.settings
POST /kahunacart/custom-fields kahunacart.settings
PATCH /kahunacart/custom-fields/{id} kahunacart.settings
DELETE /kahunacart/custom-fields/{id} kahunacart.settings
GET /kahunacart/custom-fields/values/{entity}/{entityId} kahunacart.orders.view
PUT /kahunacart/custom-fields/values/{entity}/{entityId} The permission that writes the row

The four definition routes take kahunacart.settings. Writing a value takes the permission for the row it sits on: kahunacart.products.manage for a product or variant, kahunacart.orders.manage for an order or a line, kahunacart.customers.manage for a customer or an address, and kahunacart.settings for an entity an add-on registered.

The MCP tools are list_custom_fields, create_custom_field, update_custom_field, delete_custom_field, read_custom_field_values and write_custom_field_values. See REST API and Run the store from an AI assistant.

The settings store

The settings store is plugin-scoped configuration with no database columns of its own. Settings are stored in kahunacart_settings with a namespace column, so two add-ons can each store an api_key without overwriting one another.

Register a namespace

PHP
public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterSettingsNamespaces' => ['onKahunaCartRegisterSettingsNamespaces', 0]];
}

public function onKahunaCartRegisterSettingsNamespaces(Event $event): void
{
    $event['registry']->register('warranty', [
        ['name' => 'api_key', 'scope' => 'global', 'permission' => 'kahunacart.settings'],
        ['name' => 'default_months', 'scope' => 'global', 'default' => 12, 'validate' => static function ($value): int {
            $months = (int)$value;
            if ($months < 0 || $months > 120) {
                throw new \InvalidArgumentException('Between 0 and 120 months.');
            }

            return $months;
        }],
        ['name' => 'collapsed', 'scope' => 'user', 'default' => false, 'permission' => 'kahunacart.orders.view'],
    ], 'kahunacart-warranty');
}
Key Required Notes
name yes [a-z][a-z0-9_]*, optionally dotted
scope no global (one value for the store) or user (one per admin user). Default global
permission no Checked per key on read and on write. Default kahunacart.settings
default no What a read answers before anything is written
validate no A callable handed the incoming value that returns the value to store, or throws \InvalidArgumentException with a sentence the admin shows

The namespace itself is [a-z0-9][a-z0-9-]*. First registration of a namespace wins; a second plugin claiming it is refused and logged.

Read and write

PHP
$store = KahunaCart::instance()->settingsStore();

$key = $store->get('warranty', 'api_key');
$store->set('warranty', 'default_months', 24);
$store->set('warranty', 'collapsed', true, $username);   // a user-scoped key needs the user
$all = $store->all('warranty', $username);

A write is one INSERT … ON CONFLICT (namespace, name) DO UPDATE, so two workers saving the same key at the same moment both land and the later one wins.

A user-scoped key folds the username into the stored row name rather than into a column.

The endpoints

Method Path Permission
GET /kahunacart/settings-store/{namespace} kahunacart.orders.view, then each key's own
PUT /kahunacart/settings-store/{namespace} kahunacart.orders.view, then each key's own

A read answers only the keys the caller may see. A write that names a key the caller may not change is refused whole with a 403 naming it, before anything is written. A value its validator rejects is refused whole with a 422. An unregistered namespace returns a 404.

The MCP tools are read_settings_store and write_settings_store.

The three namespaces the base plugin registers

Namespace Holds
layout Each zone's widget order, and each admin user's dev-mode switch. See Extension zones
notifications One key per notification code, carrying whether that message goes out and which template pair it renders from. See Emails
object-templates One key per customised slot. See Object templates

All three grow a key at a time through addKey() as zones, notifications and slots are registered. An add-on's namespace can do the same.

Writing kahunacart_settings directly

If your add-on writes this table without the settings store, upsert through Grav\Plugin\KahunaCart\Database\SettingsTable::upsert() rather than by hand. The table's unique key moved from (name) to (namespace, name), and an upsert has to name a conflict target that matches an existing index on SQLite and PostgreSQL. SettingsTable picks the right target from whether the column exists yet, so the same code works on a store mid-migration.