DGS Class Schema

Overview

A class schema is the recommended way to describe a DGS: a PHP class that extends ClassSchema and configures the store with fluent builders, one builder per logical group (table, fields, actions, …). It stands alongside the two older formats — XML (in tblDefs/*.xml) and an associative Array passed via the model option.

Quick Start

namespace Plugins\SchoolUsers\Store;

use Festi\Store\Schema\ClassSchema;
use Festi\Store\Schema\Builder\ITableBuilder;
use Festi\Store\Schema\Builder\IFieldsBuilder;
use Festi\Store\Schema\Builder\IActionsBuilder;
use Store;

class UsersSchema extends ClassSchema
{
    protected function table(ITableBuilder $table): ITableBuilder
    {
        return $table->name('users')
            ->primaryKey('id')
            ->rowsForPage(50);
    }

    protected function fields(IFieldsBuilder $fields): IFieldsBuilder
    {
        $fields->add('id')
            ->type(ReadonlyField::class)
            ->caption(__('ID'))
            ->width('5%');
        $fields->add('email')
            ->type(TextField::class)
            ->caption(__('Email'))
            ->required();

        return $fields;
    }

    protected function actions(IActionsBuilder $actions): IActionsBuilder
    {
        $actions->add(Store::ACTION_LIST)->caption(__('Customers'));
        $actions->add(Store::ACTION_EDIT)->caption(__('Edit'));
        $actions->add(Store::ACTION_REMOVE)->caption(__('Remove'));

        return $actions;
    }
}

Plug it in from the plugin — the schema-class shorthand is the recommended form:

// (a) Schema-class shorthand — recommended. The first argument may be the
//     FQN of a ClassSchema subclass; the framework routes it to the model
//     option and derives an ident from the class short name.
$store = $core->createStoreInstance(UsersSchema::class);

// (b) Explicit by class name — when you need a custom store ident.
$params = [
    Store::OPTION_MODEL => UsersSchema::class,
];
$store = $core->createStoreInstance('users', $params);

// (c) Explicit by instance — when the schema declares its own constructor
//     dependencies (ClassSchema itself takes none).
$params = [
    Store::OPTION_MODEL => new UsersSchema($school),
];
$store = $core->createStoreInstance('users', $params);

How the Model Is Resolved

See Resolution priority for the model option on the main DGS page for the full resolver order and the decision diagram. The same rules apply to class schemas — they are picked up either as an IStoreModelSchema instance, as a class-name string, or via IStoreSchemaProvider::getSchema() on a custom Store subclass.

Provider Interface — IStoreSchemaProvider

Custom Store subclasses commonly take extra domain dependencies via constructor:

class StaffListStore extends PluginStore
{
    public function __construct(IPlugin $plugin, SchoolEntity $school)
    {
        $this->school = $school;
        parent::__construct('staff_list', $plugin);
    }
}

Such stores can declare their schema on the class itself by implementing Festi\Store\Schema\IStoreSchemaProvider:

use Festi\Store\Schema\IStoreModelSchema;
use Festi\Store\Schema\IStoreSchemaProvider;
use Plugins\SchoolUsers\Store\Schema\StaffListSchema;

class StaffListStore extends PluginStore implements IStoreSchemaProvider
{
    public function __construct(IPlugin $plugin, protected SchoolEntity $school) { … }

    public function getSchema(): IStoreModelSchema
    {
        return new StaffListSchema($this->school);
    }
}

This mirrors the existing instanceof I*Listener pattern used across the framework (IBeforeInsertListener, IRemoveListener, IStoreProxyForeignKeyValuesListener, …).

Resolution order for stores that implement IStoreSchemaProvider:

  1. Explicit model option (still wins — useful for tests and one-off overrides).
  2. IStoreSchemaProvider::getSchema().
  3. tblDefs/{store}.xml fallback (the XML loader raises Not found model file if the XML file is missing).

getSchema() returns a ready IStoreModelSchema instance (in practice a ClassSchema subclass) — the return type enforces a valid schema at the language level, so StoreModelResolver consumes it directly.

ClassSchema is a pure builder with no store coupling — a schema that needs runtime data declares those dependencies on its own constructor and the owning store supplies them in getSchema():

class StaffListSchema extends ClassSchema
{
    public function __construct(protected SchoolEntity $school) { … }

    protected function filters(IFiltersBuilder $filters): IFiltersBuilder
    {
        $idSchool = $this->school->getID();
        return $filters->add('id_school', $idSchool);
    }
}

This keeps schema dependencies explicit and typed without threading them through a store accessor.

Section-Method Contract

Every section method follows the same shape:

protected function <section>(I<Section>Builder $builder): I<Section>Builder
{
    // configure $builder…
    return $builder;
}
  • The parameter is the builder interface (ITableBuilder, IFieldsBuilder, …). The framework instantiates the concrete builder and passes it in — your code never picks the implementation.
  • The return type is the same interface, returning the configured builder. The framework uses what you return, so subclasses can compose with parent::table($table)->….
  • Builders are mutable: in single-statement sections (e.g. table) you can return the chain expression directly; in multi-statement sections (fields, actions, …) configure first and return $builder; at the end.

ClassSchema provides a no-op default implementation of every section method. Override only the ones you need.

Method Parameter / Return type Purpose
table() ITableBuilder Table-level attributes
fields() IFieldsBuilder Field definitions
actions() IActionsBuilder List/insert/edit/remove and custom actions
groupActions() IGroupActionsBuilder Bulk actions above the grid (XML <grouped>)
relations() IRelationsBuilder Parent/child links to other tables
filters() IFiltersBuilder Pre-applied filters
search() ISearchBuilder Search form fields
listeners() IListenersBuilder DGS event listeners
aggregations() IAggregationsBuilder Column totals (sum/avg/custom)
sections() ISectionsBuilder Field grouping (tabs/widgets/wizard)
routers() IRoutersBuilder Parent-child routing across stores
highlights() IHighlightsBuilder Row CSS class rules
rules() IRulesBuilder Field visibility/availability rules
externalValues() IExternalValuesBuilder Hardcoded values applied on insert/edit

table(ITableBuilder $table): ITableBuilder

Configures the table attributes that map 1-to-1 to the <table> XML element and to the 'table' key of the Array schema. The setter names mirror the IStoreModelAttributes::OPTION_* constants — you don't write the constant names yourself.

protected function table(ITableBuilder $table): ITableBuilder
{
    return $table->name('users')
        ->primaryKey('id')
        ->defaultOrder('full_name', 'ASC')
        ->permission(SchoolUsersSections::SYSTEM_USERS)
        ->rowsForPage(50)
        ->paging('ajax');
}

Common setters: name(), primaryKey(), defaultOrder($field, $direction = 'ASC'), permission(), rowsForPage(), paging(), mode(), charset(), join(), additionalWhere(), gridEditor(), exceptionMode(), emptyMessage(), fastAdd(), fixedHeader(), sourceTimezone(), userTimezone(), errorMessage().

fields(IFieldsBuilder $fields): IFieldsBuilder

Each $fields->add($name) returns an IFieldDefinition you can configure fluently. Pass the field class to type() — TextField::class, SelectField::class, ForeignKeyField::class, etc. The factory also accepts the short literal ('text', 'select', 'foreignKey') for parity with the XML and Array loaders, but in a class schema prefer the class constant: it gives IDE navigation, refactoring, and static analysis.

protected function fields(IFieldsBuilder $fields): IFieldsBuilder
{
    $fields->add('id')
        ->type(ReadonlyField::class)
        ->caption(__('ID'))
        ->width('5%')
        ->sorting();

    $fields->add('email')
        ->type(TextField::class)
        ->caption(__('Email'))
        ->required()
        ->filter('text');

    $statusOptions = [
        'active'  => __('Active'),
        'deleted' => __('Deleted'),
    ];
    $fields->add('status')
        ->type(SelectField::class)
        ->caption(__('Status'))
        ->filter('select')
        ->options($statusOptions);

    $fields->add('id_type')
        ->type(ForeignKeyField::class)
        ->caption(__('Type'))
        ->foreignTable('users_types')
        ->foreignKeyField('id')
        ->foreignValueField('caption');

    $userRoleActiveStatus = IUserRoleEntity::STATUS_ACTIVE;
    $fields->add('id_roles')
        ->type(Many2manyField::class)
        ->caption(__('Roles'))
        ->foreignTable('user_roles')
        ->foreignKeyField('id')
        ->foreignValueField('caption')
        ->valuesWhere("user_roles.status = '{$userRoleActiveStatus}'")
        ->linkTable('users2user_roles')
        ->linkField('id_user')
        ->linkForeignField('id_role')
        ->valuesOrderBy('user_roles.caption ASC');

    return $fields;
}

options() accepts both an associative array (['id' => 'label']) and the explicit list form used in XML/Array ([['id' => 'active', 'value' => 'Active'], …]).

Common setters on IFieldDefinition: type(), caption(), required(), readonly(), trim(), format(), options(), sorting(), filter(), width(), foreignTable(), foreignKeyField(), foreignValueField(), valuesWhere(), linkTable(), linkField(), linkForeignField(), valuesOrderBy(), hide(), onlyList(), isnull(), section().

Use section() to assign a field to a form section declared in sections() — see the Field Grouping page for a worked example.

valuesWhere() restricts the lookup query for both foreignKey and many2many (e.g. 'status = \'active\''). many2many extends foreignKey, so all foreign* setters apply on top of the m2m-specific linkTable/linkField/linkForeignField (all three required) and the optional valuesOrderBy. The condition and joinValues m2m attributes go through attribute().

Anything type-specific that isn't on the interface (e.g. html5 on datetime, mode on textarea) is set through the generic attribute() setter:

$fields->add('created_at')
    ->type(DatetimeField::class)
    ->attribute('html5', true);

actions(IActionsBuilder $actions): IActionsBuilder

Use Store::ACTION_* constants instead of string literals. mode() on the builder applies to the whole toolbar — 'buttons' renders every action as a separate button instead of the default dropdown (StoreModel::OPTION_ACTIONS_MODE_*).

protected function actions(IActionsBuilder $actions): IActionsBuilder
{
    $actions->mode('buttons');

    $actions->add(Store::ACTION_LIST)->caption(__('Customers'));
    $actions->add(Store::ACTION_INSERT)->caption(__('Add User'));
    $actions->add(Store::ACTION_EDIT)->caption(__('Edit'));
    $actions->add(Store::ACTION_REMOVE)
        ->caption(__('Remove'))
        ->permission(SchoolUsersSections::SYSTEM_USERS_MANAGE);

    return $actions;
}

Common setters on IActionDefinition: caption(), title(), mode(), view(), permission(), url(), icon(), src(), addon(), js(), confirmDialog(), dialogTitle(), dialogMessage(), dialogConfirmButtonCaption(), dialogCancelButtonCaption(), button(), columns(), cancelUrl(), redirectUrl(), isLoadAllColumns(), submitButton(), submitButtonCaption(), submitButtonUrl(), relation(), plugin(), method(). Anything else (e.g. prefill, fast, execute, field, fileName) goes through attribute().

groupActions(IGroupActionsBuilder $groupActions): IGroupActionsBuilder

Bulk actions exposed above the grid that operate on the currently selected rows — the class-schema equivalent of the XML <grouped> element. Each $groupActions->add($type) returns the same IActionDefinition used by row actions, so the same fluent setters (caption(), url(), js(), confirmDialog(), dialogTitle(), dialogMessage(), permission(), …) apply. Output is keyed by type and hydrates the same 'grouped' model section the XML loader produces, so downstream consumers (toolbar template, StoreModel::getGroupActionFields()) work identically.

protected function groupActions(IGroupActionsBuilder $groupActions): IGroupActionsBuilder
{
    $groupActions->add('bulk_delete')
        ->caption(__('Delete Selected'))
        ->url(Core::getInstance()->getUrl('/items/delete/'))
        ->confirmDialog(true)
        ->dialogTitle(__('Confirm Deletion'))
        ->dialogMessage(
            __('Are you sure you want to delete the selected items?')
        );

    $groupActions->add('bulk_move')
        ->caption(__('Move to Archive'))
        ->js('moveToArchiveHandler');

    return $groupActions;
}

See Actions → Grouped Actions for the XML form and the list of attributes the bulk-action template understands.

relations(IRelationsBuilder $relations): IRelationsBuilder

protected function relations(IRelationsBuilder $relations): IRelationsBuilder
{
    $relations->add()
        ->child()
        ->field('id')
        ->foreignTable('users_devices')
        ->foreignField('id_user')
        ->treeCaption('caption');

    return $relations;
}

child() and parent() set the relation type to Store::RELATION_TYPE_CHILD / RELATION_TYPE_PARENT respectively — no need to pass the literal.

filters(IFiltersBuilder $filters): IFiltersBuilder / search(ISearchBuilder $search): ISearchBuilder

Both share the same add($field, $value) API and both emit <filter> items under their respective parent elements. filters() pre-applies values to the underlying query; search() seeds the advanced-search form. The value parameter is required — pass it explicitly.

protected function filters(IFiltersBuilder $filters): IFiltersBuilder
{
    return $filters->add('users.status', IUserEntity::STATUS_ACTIVE);
}

protected function search(ISearchBuilder $search): ISearchBuilder
{
    $search->add('users.id_school', $idSchool);
    $search->add('users.id_role', IUserEntity::ROLE_STUDENT);

    return $search;
}

listeners(IListenersBuilder $listeners): IListenersBuilder

Event identifiers are PHP constants on Store::EVENT_* — pass them directly.

protected function listeners(IListenersBuilder $listeners): IListenersBuilder
{
    $listeners->add(Store::EVENT_BEFORE_INSERT)
        ->plugin('SchoolUsers')
        ->method('onBeforeCreateUser');

    return $listeners;
}

aggregations(IAggregationsBuilder $aggregations): IAggregationsBuilder

protected function aggregations(IAggregationsBuilder $aggregations): IAggregationsBuilder
{
    $aggregations->add('amount', IAggregationsBuilder::TYPE_SUM);
    $aggregations->add('commission', IAggregationsBuilder::TYPE_AVG);
    $aggregations->add('tax', IAggregationsBuilder::TYPE_SUM);

    return $aggregations;
}

The aggregation type is required — pass one of IAggregationsBuilder::TYPE_SUM, TYPE_AVG, or TYPE_CUSTOM. There is no default.

For custom aggregations, see Aggregations — the listener wiring is identical.

sections(ISectionsBuilder $sections): ISectionsBuilder

Field grouping (tabs, widgets, wizard). mode() and columns() are attributes of the whole block — set them on the builder, not on the individual section item. mode accepts StoreModel::OPTION_SECTIONS_MODE_* values (tabs, widgets, wizard); columns only applies in widgets mode.

protected function sections(ISectionsBuilder $sections): ISectionsBuilder
{
    $sections->mode('widgets')->columns(2);

    $sections->add('profile')->caption(__('Profile'));
    $sections->add('access')->caption(__('Access'));

    return $sections;
}

routers(IRoutersBuilder $routers): IRoutersBuilder

Cross-store routing — declares a SQL JOIN between two stores. add($store) is the left side of the JOIN (the store this rule originates from), joinStore is the right side, type is the SQL JOIN type (INNER, LEFT, RIGHT, FULL), on is the ON-clause body, and joinStoreName overrides the physical SQL table name when it differs from the store ident.

protected function routers(IRoutersBuilder $routers): IRoutersBuilder
{
    $routers->add('users')
        ->joinStore('user_qr_codes')
        ->type('LEFT')
        ->on('user_qr_codes.id_user = users.id');

    return $routers;
}

highlights(IHighlightsBuilder $highlights): IHighlightsBuilder

Row CSS class rules. add($cssClass) opens a new rule; each when($field, $value) appends an AND-condition — the class is applied only when every condition evaluates to $row[$field] == $value. The emitted shape (['css' => …, 'fields' => [name => value, …]]) matches what Festi\Store\View\ListRowStyleResolver consumes; see ListRowStyleEvent in Events to add or remove row classes from code.

protected function highlights(IHighlightsBuilder $highlights): IHighlightsBuilder
{
    $highlights->add('row-archived')->when('status', 'archived');

    $highlights->add('row-vip-overdue')
        ->when('tier', 'vip')
        ->when('overdue', 1);

    return $highlights;
}

rules(IRulesBuilder $rules): IRulesBuilder

Field-level visibility and availability rules.

externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder

Values applied automatically on insert/edit (e.g. id_user from session).

protected function externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder
{
    return $values->add(
        'id_company',
        $this->company->getID()
    );
}

Dynamic Configuration

Section methods are plain PHP — branch on whatever you need. Schemas that need runtime data declare it on their own constructor (the owning store passes it in getSchema()). Core is reachable through Core::getInstance() whenever you need plugin, settings, or session access.

protected function fields(IFieldsBuilder $fields): IFieldsBuilder
{
    $fields->add('email')
        ->type(TextField::class)
        ->caption(__('Email'))
        ->required();

    if (Core::getInstance()->getSystemPlugin()
        ->hasUserPermissionToSection('users_full_view')
    ) {
        $fields->add('internal_notes')
            ->type(TextareaField::class)
            ->caption(__('Internal Notes'));
    }

    return $fields;
}

There is no separate configure() hook — every section method is the hook for its group.

Subclassing and Composition

Because each section method both takes and returns its builder interface, subclasses can compose on top of a parent schema without re-stating the base configuration:

class AdminUsersSchema extends UsersSchema
{
    protected function table(ITableBuilder $table): ITableBuilder
    {
        return parent::table($table)
            ->rowsForPage(200)
            ->permission(SchoolUsersSections::SYSTEM_USERS_ADMIN);
    }

    protected function fields(IFieldsBuilder $fields): IFieldsBuilder
    {
        parent::fields($fields);

        $fields->add('last_login')->type(DatetimeField::class)->onlyList(true);

        return $fields;
    }
}

Field Types

IFieldDefinition::type() accepts:

  1. A fully qualified class name of any class that implements IStoreField — TextField::class, ForeignKeyField::class, or a custom field class from your plugin. This is the preferred form in a class schema, for IDE navigation, safe refactoring, and static analysis.
  2. A short literal — 'text', 'textarea', 'readonly', 'select', 'checkbox', 'number', 'price', 'datetime', 'password', 'file', 'image', 'foreignKey', 'many2many', 'composite', 'md5', 'serialize', 'sql'. The framework resolves it to {Ucfirst}Field and instantiates it. Accepted for parity with the XML and Array loaders.

Both forms go through the same StoreModel::createFieldInstance() factory used by XML and Array loaders.

Mapping: XML / Array / Class

XML Array key Class section method
<table name="…" primaryKey="…" /> 'table' table()
<field type="text" name="email" /> 'fields' fields()
<action type="list" caption="…" /> 'actions' actions()
<grouped>…<item type="…" /></grouped> 'grouped' groupActions()
<link type="child" foreignTable="…" /> 'relations' relations()
<filter field="…" /> 'filters' filters()
<search>…</search> 'search' search()
<listener event="…" plugin="…" method="…"> 'listeners' listeners()
<aggregation field="…" type="…" /> 'aggregations' aggregations()
<sections>…</sections> 'sections' sections()
<route store="…" joinStore="…" /> (in <routers>) 'routers' routers()
<rule cssClass="…">…</rule> (in highlights) 'highlights' highlights()
<rules>…</rules> 'rules' rules()
<value field="…" /> (in externalValues) 'externalValues' externalValues()

The class schema produces the same internal StoreModel as the other two — all downstream behaviour (events, fields, actions, proxy, view) is identical.

Limitations

  • No PHP-template preprocessing. XML files are passed through the templating engine before parsing, so they can embed <?php … ?> blocks. A class schema has no such phase; every dynamic decision lives in normal PHP control flow inside a section method.

See Also

  • Table — table-level attributes reference.
  • Fields — full catalogue of field types and per-type options.
  • Actions — action types and customisation.
  • Events — DGS event reference.
  • DGS Class Approach — extending Store / PluginStore (orthogonal to schema description; you can combine both).