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.