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:
- Explicit
modeloption (still wins — useful for tests and one-off overrides). IStoreSchemaProvider::getSchema().tblDefs/{store}.xmlfallback (the XML loader raisesNot found model fileif 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 andreturn $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:
- 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. - 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}Fieldand 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).