Skip to content

Custom fields

Custom fields let a module attach declarative field groups to CMS content — a "Listing details" group with a price and a checkbox, say — without a schema change. Values live in the page's existing meta JSON, so they're versioned by page_version and org-cascaded for free, exactly like the built-in meta.seo.* fields.

This is the clean answer to the WordPress ACF model: ACF stores every field as its own wp_postmeta row (the EAV pattern that makes WP slow), while Tiger keeps a group's values in one already-loaded row. And because groups are declared (in config or code), the platform can show what fields a module adds before it's installed — the inspectability EAV structurally can't offer.

Declare a group

A module contributes a group two equal ways (mirroring Tiger_Admin_Nav). The common path is a configs/fields.php in the module, auto-discovered at bootstrap for every active module — it returns ['groups' => [ … ]] (a PHP array, not .ini, because a group nests a list of fields):

<?php
// modules/listings/configs/fields.php
return [
    'groups' => [
        [
            'key'   => 'listing',
            'label' => 'Listing details',
            'types' => ['page', 'article'],   // empty/absent = every page type
            'order' => 50,
            'fields' => [
                ['key' => 'price',    'label' => 'Price',     'type' => 'number', 'required' => true],
                ['key' => 'featured', 'label' => 'Featured',  'type' => 'checkbox'],
                ['key' => 'region',   'label' => 'Region',    'type' => 'select',
                 'options' => ['north', 'south'], 'help' => 'Sales region'],
            ],
        ],
    ],
];

For a computed group, register it from the module's Bootstrap instead:

protected function _initListingFields()
{
    Tiger_Fields::register([
        'key' => 'listing', 'label' => 'Listing details', 'types' => ['page'],
        'fields' => [ ['key' => 'price', 'label' => 'Price', 'type' => 'number'] ],
    ]);
}

Supported field types: text, textarea, select, checkbox, number, url, date, media. Each field also takes required, options (for select), help, and placeholder. Group discovery skips inactive modules.

Where values are stored

Values live at meta.fields.<group>.<field> inside the page row's meta JSON. There's no new table and no migration — the CMS page editor renders the declared groups for the page's type, and Cms_Service_Page writes them back on save. The save seam (Tiger_Fields::applyToMeta()) accepts only declared groups/fields for that page type — stray posted keys are ignored — and coerces each value to its declared type (a number becomes numeric, an absent checkbox becomes false, a select outside its options is dropped). So the stored shape is always exactly what you declared.

Read a value on the front end

In a view, use the pageField() helper — it returns the value raw, so escape at the point of output like any other dynamic value:

<p>Price: <?= $this->escape($this->pageField($page, 'listing.price')) ?></p>

<?php if ($this->pageField($page, 'listing.featured')): ?>
    <span class="badge">Featured</span>
<?php endif; ?>

<?= $this->escape($this->pageField($page, 'listing.region', 'north')) ?>  <!-- default -->

Outside a view, the registry reads the same values directly from a page row:

$price  = Tiger_Fields::value($pageRow, 'listing.price');       // one field (with optional default)
$values = Tiger_Fields::valuesFor($pageRow, 'listing');         // the whole group, keyed by field
$groups = Tiger_Fields::forType('page');                        // groups that apply to a page type

value()/valuesFor() accept a page row, its ->toArray(), or a plain array — they decode meta for you.

See Forms & validation for the field-type and validator vocabulary, and Models & schema for why JSON-shaped data (like meta) is stored as LONGTEXT.