Skip to content

Features Config Reference

features is a top-level key in the module config that controls what backend services, frontend pages, and mobile pages are generated — and how they behave.

json
"features": {
  "backend":    { "list": {}, "create": {}, "view": {}, "edit": {}, "delete": {}, "deleteCheck": {} },
  "frontend":   { "list": {}, "create": {}, "view": {}, "edit": {}, "delete": {} },
  "mobile_app": { "enabled": false, "mode": "online" }
}

features.backend

features.backend.list

Controls {Module}ListService.php.

json
"list": {
  "filterableFields":        ["name", "code"],
  "sortableFields":          ["name", "created_at"],
  "eagerLoadRelationships":  ["category", "status"],
  "filterableRelationships": [],
  "filterFields":            [],
  "default_list_filters": [
    { "column": "status_id", "operator": "eq", "value": 1 }
  ],
  "bulk_actions": [
    { "key": "activate", "status_target": "ACTIVE", "label": "Activate" },
    { "key": "archive",  "label": "Archive" }
  ],
  "import": false,
  "export": false,
  "endpoint": {
    "method":     "GET",
    "path":       "/products",
    "permission": "Products.list"
  }
}
KeyTypeDescription
filterableFieldsstring[]Column names that can be filtered by the API client.
sortableFieldsstring[]Column names the list can be sorted by.
eagerLoadRelationshipsstring[]Relationship method names to eager-load (e.g. "category", "status").
filterableRelationshipsarrayRelationships exposed as filter options.
filterFieldsarrayUI filter field definitions for the frontend filter panel. Leave empty (the common case) to auto-derive type-aware entries from filterableFields — see Filter fields: auto-derivation and default filters below.
default_list_filtersarrayHard-coded filters always applied to the query. Each entry: { column, operator, value }.
bulk_actionsarrayBulk action keys available on the list page — a flat array (unlike top-level actions, which is keyed). Each entry: { key: string, status_target?: string, label?: string, icon?: string, requiresPermission?: string, confirmMessage?: string, variant?: string }. With status_target, the generated service does a real $model->update(['status_id' => {Module}Model::{STATUS_TARGET_CONST}]) — requires an integer status_id column and a matching key in the module's own constants map, not a plain string status column. Without status_target, it's an empty TODO stub you hand-fill, same as a no-UI action. Renders a real bulk-action toolbar in the generated frontend — see Frontend wiring below. See the Actions Cookbook example for a full worked example.
importbooleanGenerate an import (CSV/Excel upload) endpoint, dry-run support, and a real import dialog in the generated frontend. Default false. See Frontend wiring below.
exportbooleanGenerate an export (CSV/Excel download) endpoint and a real export button in the generated frontend. Default false. See Frontend wiring below.
endpointobjectRoute config — { method, path, permission }.

Frontend wiring for bulk actions, export, and import

Since v2.29.0

Verified directly against ListPageGenerator/CustomFeatureTabComponentGenerator (src/Generators/Frontend/Pages/ListPageGenerator.php, src/Generators/Frontend/Components/CustomFeatures/CustomFeatureTabComponentGenerator.php) and a live-generated {Module}ListPage.vue and delegation tab component.

All three of these keys drive real UI in the generated frontend, not just a backend endpoint — both the standalone {Module}ListPage.vue and a delegation's own tab component render through the shared CrudListPanel component (SYSTEM_SHELL/FRONTEND, hand-maintained), which is wired with :enable-export, :enable-bulk-actions, :bulk-actions, :enable-import, :bulk-action-permission, and :import-permission computed directly from this config block:

  • export: true adds an export dropdown (CSV/XLSX/PDF) to the list toolbar. It reuses the surface's own .list permission — there is no dedicated export permission prop at all, matching how the standalone export endpoint already reuses {Module}.list server-side.
  • bulk_actions (non-empty) adds a bulk-action toolbar that appears once one or more rows are selected, plus a "select all N matching the filter" banner for dispatching the action against a server-resolved set rather than just the checked rows. Gated by {Module}.bulkAction.
  • import: true adds an import dialog (file input, dry-run checkbox, CSV/XLSX template-download buttons). Gated by {Module}.import.

This applies to delegations too: a delegation's own operations.list.backend.{bulk_actions,export,import} (nested under the delegation's list operation, not the top-level features.backend.list) generate the same UI inside the delegation's tab, permission-gated against the related module's own {RelatedModule}.bulkAction/.import (see Delegations › the list operation) — not a delegation-specific permission.

Not retroactive — only a module generated or regenerated against v2.29.0 or later picks up this frontend wiring.

Filter fields: auto-derivation and default filters

Since v2.27.0

Verified directly against BaseServiceGenerator::generateFilterFields(), generateFilterableFields(), getFilterFieldType(), and buildFilterFieldOptions() (src/Generators/Backend/Services/BaseServiceGenerator.php).

Most module configs never hand-author filterFields at all — leaving it empty (or omitting it) triggers auto-derivation from filterableFields, with the UI control type inferred from each column's real type rather than every field defaulting to a plain text box:

Column shapeInferred typeNotes
Foreign key (type: 'foreignId' in config['columns'], or a *_id column name)select_paginatedLoads its own options live via ApiSelect2 — no options array needed.
Enum column (has enum_values)selectoptions is a real {name, id}[] list built from enum_values, humanized via ucwords().
Boolean column, or a column named is_default/is_activeselectoptions is a hardcoded [{name: 'Yes', id: 1}, {name: 'No', id: 0}] pair.
integer, bigInteger, or decimalnumber
date, datetime, or timestampdateRenders as a date-picker filter even for a full datetime column — see the note below.
Anything else (plain string, text, etc.)text

A field derived this way gets key (the column name), a label humanized from the column name (_id/_at suffixes stripped before title-casing — category_id"Category"), and type/options as above. A hand-authored filterFields entry always wins — auto-derivation only runs when filterFields itself is empty.

id, uuid, and created_at are always appended, regardless of config (as of v2.27.0 — not something you opt into):

  • id and uuid and created_at are always added to the backend filterableFields allow-list (generateFilterableFields()'s return value), even if none of the three appear in your config at all.
  • id (type: 'text'), uuid (type: 'text'), and created_at (type: 'date') are always added to the frontend-facing filterFields array (generateFilterFields()), each only if a config-supplied or auto-derived entry for that same key isn't already present — an existing entry for id/uuid/created_at is never overridden or duplicated.

Every generated table carries id, uuid, and created_at via the standard migration columns, so this guarantees a filterable-by-date and filterable-by-identifier experience on every list page with zero config. created_at filters as date even though the underlying column is a full datetime — the companion ListServiceTrait::applyFilter() fix (in the consuming app) expands a bare "YYYY-MM-DD" value into a whole-day range rather than requiring an exact-instant match.

This is not retroactive. An already-generated module only picks up the new default filters the next time it is regenerated (with or without --force — this only adds new array entries, it does not touch hand-written code).

Before/after example. A minimal config with an FK column and an enum column, and no filterFields:

json
{
  "features": {
    "backend": {
      "list": {
        "filterableFields": ["category_id", "status"]
      }
    }
  },
  "columns": [
    { "name": "category_id", "type": "foreignId" },
    { "name": "status", "type": "string", "enum_values": ["draft", "published", "archived"] }
  ]
}

produces a backend filterableFields allow-list of ['category_id', 'status', 'id', 'uuid', 'created_at'], and a generated filterFields array (shown here as JSON for readability — the real generated file is a PHP array literal with the same keys and values) of:

json
[
  { "key": "category_id", "label": "Category", "type": "select_paginated" },
  {
    "key": "status", "label": "Status", "type": "select",
    "options": [
      { "name": "Draft",     "id": "draft" },
      { "name": "Published", "id": "published" },
      { "name": "Archived",  "id": "archived" }
    ]
  },
  { "key": "id",         "label": "ID",         "type": "text" },
  { "key": "uuid",       "label": "UUID",       "type": "text" },
  { "key": "created_at", "label": "Created At", "type": "date" }
]

features.backend.create

Controls {Module}CreateService.php.

json
"create": {
  "fields": [
    {
      "field":    "name",
      "rules":    "required|string|max:255",
      "messages": { "required": "Name is required." }
    },
    {
      "field": "category_id",
      "rules": "required|exists:categories,id"
    }
  ],
  "endpoint": {
    "method":     "POST",
    "path":       "/products",
    "permission": "Products.create"
  }
}
KeyTypeDescription
fieldsarrayValidation rule definitions. Each: { field, rules, messages? }. rules is a Laravel validation string.
endpointobjectRoute config.

features.backend.view

Controls {Module}ViewService.php.

json
"view": {
  "eagerLoadRelationships": ["category", "status", "createdBy"],
  "endpoint": {
    "method":     "GET",
    "path":       "/products/{uuid}",
    "permission": "Products.view"
  }
}

features.backend.edit

Same structure as create. Controls {Module}EditService.php.

json
"edit": {
  "fields": [
    { "field": "name",        "rules": "required|string|max:255" },
    { "field": "category_id", "rules": "required|exists:categories,id" }
  ],
  "endpoint": {
    "method":     "PUT",
    "path":       "/products/{uuid}",
    "permission": "Products.edit"
  }
}

features.backend.delete

Controls {Module}DeleteService.php.

json
"delete": {
  "success_message": "Product deleted successfully.",
  "endpoint": {
    "method":     "DELETE",
    "path":       "/products/{uuid}",
    "permission": "Products.delete"
  }
}

features.backend.deleteCheck

Controls {Module}DeleteCheckService.php. No configuration needed — the generator emits a stub that checks foreign_key_graph references.

json
"deleteCheck": {}

features.backend.createSplash / features.backend.editSplash

Splash variants render a secondary form section for modules that have constants defined. Omit these keys if no constants exist — the generator will skip them.

json
"createSplash": {},
"editSplash": {}

features.frontend

features.frontend.list

Controls {Module}ListPage.vue and {Module}ListComponent.vue.

json
"list": {
  "primaryField": "name",
  "fields": [
    {
      "key":      "name",
      "title":    "Product Name",
      "sortable": true,
      "data":     "name",
      "type":     "text",
      "class":    "font-medium text-gray-900"
    },
    {
      "key":      "category_id",
      "title":    "Category",
      "sortable": false,
      "data":     "category?.name",
      "type":     "text",
      "class":    ""
    },
    {
      "key":      "is_active",
      "title":    "Active",
      "sortable": false,
      "data":     "is_active",
      "type":     "boolean",
      "class":    ""
    },
    {
      "key":            "internal_notes",
      "title":          "Internal Notes",
      "sortable":       false,
      "type":           "text",
      "defaultVisible": false
    }
  ]
}
KeyTypeDescription
primaryFieldstringColumn name used as the record title in breadcrumbs and delete confirmations.
fieldsarrayTable column definitions — see field shape below.

Field shape:

KeyTypeDescription
keystringColumn name.
titlestringColumn header label.
sortablebooleanWhether the column header triggers server-side sort.
datastringJS accessor path, e.g. "category?.name" for FK display.
type"text" | "boolean"Renders a tick/cross icon for boolean.
classstringTailwind classes appended to the cell.
defaultVisiblebooleanOptional, default true. Set false to start the column hidden behind ReportTable.vue's existing "View" column-visibility toggle — the user can still show it manually; this only sets the starting state. Never emitted for the primary/pinned column (primaryField), since fixed columns are always shown and the flag would be a no-op there.

Actions column is automatic. BaseComponentGenerator::generateColumnsFromListFields() always appends a trailing { key: "actions", label: "", width: 120, align: 'right' } column to line up with the View/Edit/Delete buttons rendered by list/page.stub's <template #cell-actions> slot. Do not add an actions entry to fields yourself — if one is present, the generator detects it and skips the auto-append rather than emitting a duplicate.


features.frontend.create

Controls {Module}CreatePage.vue and {Module}CreateFormComponent.vue.

json
"create": {
  "fields": [
    {
      "field":       "name",
      "label":       "Product Name",
      "placeholder": "Enter product name",
      "required":    true,
      "field_type":  "input",
      "type":        "text"
    },
    {
      "field":        "category_id",
      "label":        "Category",
      "placeholder":  "Select a category",
      "required":     true,
      "field_type":   "api-select",
      "type":         "text",
      "api_url":      "/select/categories",
      "option_label": "name",
      "option_value": "id",
      "per_page":     15,
      "multiple":     false
    },
    {
      "field":      "price",
      "label":      "Price",
      "required":   true,
      "field_type": "number-input",
      "type":       "number",
      "decimals":   2
    }
  ],
  "button_text": "Create Product",
  "sections":    [],
  "hiddens":     [],
  "defaults":    []
}

Field shape:

KeyTypeDescription
fieldstringColumn name.
labelstringForm label.
placeholderstringInput placeholder text.
requiredbooleanWhether the field is mandatory in UI.
field_typestringSee Field Types below.
typestringData type hint: "text", "number", "boolean", "date".
api_urlstring(api-select only) Endpoint path for async options.
option_labelstring(api-select only) Key to display from response items.
option_valuestring(api-select only) Key to use as the submitted value.
per_pagenumber(api-select only) Page size for the options list.
multipleboolean(api-select only) Allow multi-select.
decimalsnumber(number-input only) Decimal places.
splashKeystring(splash fields only) Key of the constant set from constants[].
hiddensarrayHidden fields preset with values: [{ field, value }].
defaultsarrayDefault values pre-filled in the form: [{ field, value }].
inline_createboolean(api-select/FK fields only) Set false to opt an individual field OUT of the default "Add New" affordance below. Rarely needed — see Default "Add New" for FK select fields.
create_form_modulestring(api-select/FK fields only) Explicit override for which module's CreateForm.vue the "Add New" button opens — trusted as-is, unverified, same precedence as endpoint.path/endpoint.permission overrides elsewhere. Only needed when auto-detection (below) can't or shouldn't apply.

Field Types

field_typeDescription
inputStandard text input.
textareaMulti-line text area.
number-inputNumeric input with optional decimal places.
checkboxBoolean toggle/checkbox.
dateDate or datetime picker.
api-selectAsync-loaded searchable dropdown (for FK fields).

Default "Add New" for FK select fields

Since v2.30.0

Verified directly against BaseComponentGenerator::resolveInlineCreateModule() and IntrospectionToConfig::buildFrontendFormFields() (src/Generators/Frontend/Components/BaseComponentGenerator.php, src/Schema/IntrospectionToConfig.php).

An api-select field for a FK column now gets a small "+" button next to the dropdown by default — clicking it opens the related module's own CreateForm.vue in a modal, so a user picking e.g. a Location doesn't have to abandon the form they're on just to go create one first. Gated by hasPermission('{RelatedModule}.create') — a user without that permission never sees the button at all.

This is auto-detected, never guessed: the field must resolve to a real relatedModule (from real FK/foreign_table introspection — hand-authored api-select fields need an explicit create_form_module instead, see the field-shape table above), it must not be self-referential (a FK column pointing back at its own module, e.g. a parent_id hierarchy field, never gets this — a "Create X" modal opening from inside X's own create form is confusing, not helpful), and the target {RelatedModule}CreateForm.vue must actually exist on disk — checked directly with file_exists(), not a module.json feature flag (a module's own features.frontend.create config key was confirmed, against a real consuming project, to drift out of sync with whether a working CreateForm.vue actually exists — trusting it here would make this default silently inert for exactly the modules most likely to be a real target). A field that fails any of these checks silently stays a plain dropdown — no error, no broken build.

Set "inline_create": false on a field to opt it out explicitly regardless of what auto-detection would otherwise decide.

Not retroactive — only a module generated or regenerated against v2.30.0 or later picks up this default. Mobile-app forms are not covered — no equivalent mechanism exists there today.


features.frontend.edit

Same structure as create. button_text defaults to "Update {Module}".


features.frontend.view

Controls {Module}DetailsOverviewPage.vue.

json
"view": {
  "titleData": "name",
  "idParam":   "uuid",
  "fields": [
    { "title": "Name",     "data": "name",           "type": "text",    "format": "text" },
    { "title": "Category", "data": "category?.name", "type": "text",    "format": "text" },
    { "title": "Active",   "data": "is_active",       "type": "boolean", "format": "text" },
    { "title": "Phone",    "data": "phone",           "type": "text",    "group": "Contact Info" },
    { "title": "Email",    "data": "email",           "type": "text",    "group": "Contact Info" }
  ],
  "badges": []
}
KeyTypeDescription
titleDatastringColumn name used as the page/breadcrumb title.
idParamstringURL param for the record identifier. Default "uuid".
fieldsarrayView field definitions.
badgesarrayBadge field definitions (colored status chips).

Grouping fields into side-by-side columns (v2.50.0). Add a group key (any string label) to one or more entries in fields[] — every field sharing the same group value renders together in its own labeled column, and the overview card becomes an N-column grid (one column per distinct group, in order of first appearance). If no field in fields[] sets group, output is unchanged from before this existed — the normal single stacked list in one card. The moment any field sets group, the whole section becomes a grid: fields without a group key don't get a separate flat card of their own — each becomes one more, unlabeled, column in that same grid, positioned by where it first appears among all fields. In the example above (mixed — three fields have no group, two share "Contact Info"), the result is a 2-column card: one unlabeled column holding Name/Category/Active stacked, one labeled "Contact Info" column holding Phone/Email stacked. Put every field you want in the flat list under the same group value if you want them kept together and labeled, or leave group off every field to keep today's plain single-column layout.


features.frontend.delete

Controls {Module}DeleteFormComponent.vue.

json
"delete": {
  "fields": [
    { "title": "Name",  "key": "name" },
    { "title": "Code",  "key": "code" }
  ]
}

Fields shown in the delete confirmation dialog so the user sees what they're deleting.


features.mobile_app

See mobile-config.md for the full reference.

json
"mobile_app": {
  "enabled": true,
  "mode":    "online",
  "icon":    "PackageIcon",
  "list": {
    "card": {
      "titleField":     "name",
      "subtitleFields": ["code"],
      "bodyFields":     ["description"],
      "footerBadge":    "status?.name"
    }
  }
}

Released under the Apache-2.0 License.