Skip to content

Module Config Reference ​

The module config is a PHP array (or JSON object) that fully describes a single module to be generated. It is the primary input for all generator classes.


Top-Level Keys ​

json
{
  "id":                    "string (UUID v5 derived from module name)",
  "module_name":           "Products",
  "module_type":           "Custom",
  "table_name":            "products",
  "id_type":               "autoincrement | uuid | manual",
  "module_group_name":     "Core | Custom | null",
  "connection":            "mysql (optional, defaults to config('generator.default_connection'))",
  "version":               "1.0.0",
  "has_timestamps":        true,
  "has_soft_deletes":      false,
  "has_uuid":              true,
  "has_creator_updater":   true,
  "model_hand_maintained": false,
  "columns":               [],
  "indexes":               [],
  "unique_constraints":    [],
  "morphs":                [],
  "inline_items":          [],
  "file_columns":          [],
  "features":              {},
  "delegations":           {},
  "actions":               {},
  "processors":            [],
  "seeder":                { "data": [], "permissions": [] },
  "menu_config":           {},
  "constants":             {},
  "json_rules":            {}
}
KeyTypeRequiredDescription
idstringNoUUID v5 identifier. Auto-set by introspection.
module_namestringYesStudlyCase singular name, e.g. Products.
module_typestringNo"Custom", "Core", or "System". Affects namespace/path.
table_namestringYesExact DB table name, e.g. products.
id_typestringEffectively yes"autoincrement", "uuid", or "manual" — read with no ?? fallback by MigrationGenerator/ModelGenerator/SeederGenerator, so omitting it is a real error, not a default. Any other value falls through MigrationGenerator's switch to a plain $table->id().
module_group_namestring|nullNoSub-group label. Used in some menu groupings.
connectionstringNoDB connection name for the generated Model/migration. Defaults to config('generator.default_connection').
versionstringNoSemantic version. Default "1.0.0".
has_timestamps / has_soft_deletes / has_uuid / has_creator_updaterbooleanNoThe single source of truth every generator defers to — see ModuleConfigContract. Defaults: has_timestamps/has_uuid/has_creator_updater → true, has_soft_deletes → false (soft deletes is opt-in, not assumed).
model_hand_maintainedbooleanNoDefault false. When true, ModelGenerator writes the Model file once and never touches it again on regeneration (no --force escape hatch).
columnsarrayYesColumn definitions — see columns.md.
indexesarrayNoPlain (non-unique) and single-column-unique indexes.
unique_constraintsarrayNoComposite (2+ column) unique constraints: [{columns: string[], name?: string}]. Renders through the same $table->unique(...) path as an indexes entry marked unique: true — don't declare the same column set in both, MigrationGenerator doesn't dedupe and will emit the DDL twice.
morphsarrayNoPolymorphic relationship declarations — see below. A genuine flat array (unlike delegations/actions) — auto-detected by introspection, rarely hand-authored.
inline_itemsarrayNoParent-embedded child rows (e.g. Order → Order Items), rendered and saved inline on this module's own Create/Edit/View pages — no separate child page. See Inline Items example.
file_columnsarray of stringNoColumn names that hold a file/media reference. Routes the column to a file validation rule instead of its DB-derived one, and to a media-upload beforeCreate/beforeUpdate hook, instead of being treated as a plain scalar.
featuresobjectYesBackend + frontend + mobile feature config — see features-config.md.
delegationsobjectNoRelated-module tab/modal panels, keyed by delegation key — see delegations.md.
actionsobjectNoCustom action buttons and services, keyed by action key — see actions.md.
processorsarrayNoPipeline hooks (before/after save/delete) — see processors.md.
seederobjectNo{data: [...row objects...], permissions: [...]} — not a flat array, see below.
menu_configobject|nullNoNavigation placement — see below.
constantsobjectNoFlat { CONST_NAME: value } map — see below.
relationsobjectNoManual-relations escape hatch: { hasMany: [...], belongsToMany: [...], morphMany: [...] }, each entry { module, method, ... } — rendered onto this module's own generated Model by ModelGenerator::generateManualInverseRelationships(). morphMany (v3.4.0) is how a morph target module (e.g. Vendors, on the receiving end of a payable morph declared on Payments) gets a real payments(): MorphMany relation without hand-splicing the Model file — see Polymorphic Relations. Preserved across --force like delegations/actions/constants.
skip_convention_checkbooleanNoDefault false. Opts this module out of IntrospectionToConfig's audit-column naming-convention check (created_by/updated_by/etc. must match the project's documented convention, or introspection throws) — use only when a table genuinely can't follow the convention, not as a quick fix for a real mismatch.
sensitive_columnsobjectNo{include: string[], exclude: string[], storage: {column: mode}} (include/exclude v3.5.17, storage v3.5.17+). Overrides ModuleConfigContract's name-based secret-column heuristic (password, secret, token, api_key, pin, otp, salt, and suffixes like _hash/_password/_secret/_token, excluding _id/_at columns). A sensitive column is put into the generated Model's $hidden, excluded from filter/sort allow-lists (even a hand-authored one) and from list/view/delete/edit fields, and never chosen as primaryField/titleData — it still gets a create field, rendered as a masked password input. What is NOT hidden: columns[] itself, the backend/frontend create field, direct DB writes, and activity-history snapshots. make:module in a SYSTEM_SHELL-style consuming app carries this forward across --force only via ModuleScaffolder::mergePersistedFields()'s own sensitive_columns entry — a bare IntrospectionToConfig::build() call has no persistence of its own.
json_rulesobjectNov3.5.21+. Per-json-column declaration of its nested shape — see json_rules Object below.

sensitive_columns.storage (v3.5.17+). How a sensitive column is WRITTEN, not just hidden:

Column matches (lower-cased)Default storage
exact password, pin, otp, otp_code, salt; or ends _password, _pin, _otp, _salthashed
exact secret, token, api_key, private_key; ends _secret, _token, _api_key, _private_key; or a _-segment exactly secretencrypted
exact remember_tokenplain always — Laravel's EloquentUserProvider::retrieveByToken() compares it with hash_equals() against the raw cookie value, never Hash::check(); casting it hashed breaks "remember me"
any other sensitive column (chiefly *_hash, e.g. key_hash/token_hash)plain — the application already computed the hash before ever assigning it to the model; casting hashed would hash the hash
not sensitive at allplain

hashed casts to Eloquent's 'hashed' cast (one-way — the plaintext is never needed again). encrypted casts to 'encrypted' (reversible, transparent on model attribute access — needed when the application itself must read the value back, e.g. NJIWA's Webhooks secret computing an outgoing HMAC signature). sensitive_columns.storage.<column> overrides either direction and must be one of hashed/encrypted/plain, or generation throws. MigrationGenerator widens a hashed string column to at least 255 characters, forces an encrypted column to text with no length (ciphertext is routinely 2-4x longer than the plaintext), and refuses a unique constraint on either mode outright — the stored bytes differ on every write, so a unique index can neither prevent two rows sharing the same real secret nor allow re-saving a row's own unchanged value. Only a column's first migration is affected: retrofitting storage onto an already-migrated column changes the Model's cast on the next --force but does not widen the existing DB column or touch rows already written — see the operator migration template in the engine's v3.5.17 changelog entry for how to do that by hand.

List filters. features.backend.list.filterFields can be left empty — it auto-derives type-aware filters from filterableFields, and id/uuid/ created_at are always added as default filters regardless of config. See features-config.md § Filter fields for the full behavior.


morphs Array ​

Declare polymorphic relationships on this table. Auto-detected by schema introspection (a {prefix}_type/{prefix}_id column pair) — name/type_column/id_column are populated for you; only targets is ever hand-authored.

json
"morphs": [
  {
    "name": "commentable",
    "type_column": "commentable_type",
    "id_column": "commentable_id",
    "targets": [
      { "alias": "post", "model": "App\\Project\\Modules\\Custom\\Posts\\PostsModel", "module": "Posts", "label": "Post" }
    ]
  }
]

The generator uses this to emit a morphTo() relationship method — always, whether or not targets is set. The migration side is not unconditional: MigrationGenerator only collapses this morph's type_column/id_column pair into $table->morphs('commentable') when those two names also appear as regular entries in columns[] — add them there first, with the exact same names. Declare morphs[] without matching columns[] entries and no $table->morphs() line (and no columns for the pair at all) gets emitted. morphMany()/morphOne() (the inverse, on the target side) is not emitted — that stays a manual add if you want e.g. $post->comments to work.

targets (optional, never auto-guessed) drives two things once populated: a morph-select create/edit field (type dropdown + API-backed record picker, replacing the fallback plain text/number input pair) and a Relation::morphMap() registration on this module's own generated boot() method. Each entry requires alias/model/module; label falls back to alias if omitted, and option_label is optional (which field to show in the record picker, defaults to name). The same alias registered for two different model values across the whole project is a hard-fail at generation time — see the morphs example page for the full config shape and behavior.


Controls where this module appears in the navigation sidebar.

json
"menu_config": {
  "enabled":       true,
  "section":       "main",
  "section_label": "Main Menu",
  "icon":          "Package",
  "permission":    "Products.list",
  "nested":        false,
  "items": [
    {
      "title":      "All Products",
      "url":        "/products/list",
      "icon":       "List",
      "permission": "Products.list",
      "children":   []
    }
  ]
}
KeyTypeDefaultDescription
enabledbooleantrueSet false to hide from nav entirely.
sectionstring"configurations" for module_type: Custom/Core, "main" for SystemID of the nav section to place this module in — derived per module_type when omitted, not a single fixed default.
section_labelstring—Optional override for the section heading text.
iconstringname-heuristic, "File" last resortLucide icon name. When omitted, guessed from the module name against a ~40-entry word-stem table; "File" only when nothing matches.
permissionstring"{Module}.list"Guard permission for this nav item.
nestedbooleanfalseIf true, renders a parent item with List and Create children.
itemsarray—Fully custom nav items. Overrides the auto-generated entry.

constants Object ​

Corrected 2026-08-02

constants is a flat key → value map, not an array of named groups. This page previously showed [{"name": "STATUS", "values": [...]}] — verified against ModelGenerator::generateConstants()'s actual source: foreach ($constants as $name => $value) { ... "public const {$name} = ...;" }.

Defines PHP public const values emitted directly on the generated model — a flat map, any scalar value: numeric values are emitted unquoted (public const ACTIVE = 1;), anything else is quoted as a PHP string (public const ACCEPTED = 'ACCEPTED';) — works identically well for a string workflow-status token as for a numeric seeded-row id.

json
"constants": {
  "ACTIVE":   1,
  "INACTIVE": 2,
  "RECEIVED": 3
}

constants itself does not populate any dropdown or splash data — that's a separate key, splashData, nested inside createSplash/editSplash (see features-config.md). constants has two independent consumers:

  • features.backend.list.bulk_actions[].status_target (see features-config.md) references a constant here by name — but note the generated bulk-action body always writes to a hardcoded status_id column, so this only works when your table's status column is literally named status_id.
  • A non-empty constants is half of the gate that registers and calls the Create/Edit splash pre-fetch route — see the warning under createSplash/editSplash above. constants alone, with no matching createSplash/editSplash key, does nothing beyond emitting the const — it does not trigger any network call, since v3.4.7.

constants is written by one generator: the Model

A scoped regenerate that leaves Model out (--only=Controller --only=Routes --only=CreateService ...) runs every selected generator to success, prints no error, and leaves SamplesModel::PENDING_COLLECTION undefined until something references it and dies with Undefined constant. Include --only=Model when constants changed (it overwrites {Module}Model.php), or define the constants in a hand-maintained Model. SYSTEM_SHELL's ModuleScaffolder (and any scaffolder that copies it) now prints a warning naming the missing constants when a --only run leaves Model out; it never adds Model on its own, because that would overwrite hand edits.

Empty maps are written as {}. delegations, actions, constants and json_rules are keyed maps, so a fresh module.json writes them as {} (v3.5.31), not []; the empty and populated states have the same shape. Readers see [] either way (json_decode(..., true)).


seeder Object ​

Corrected 2026-08-15

seeder is an object with data/permissions keys, not a flat array of row objects — verified against SeederGenerator.php: $this->seedData = $config['seeder']['data'] ?? []; and $this->permissions = $this->mergeListPermissions($config['seeder']['permissions'] ?? [], ...).

json
"seeder": {
  "data": [
    { "name": "Electronics", "code": "ELEC", "color": "blue" },
    { "name": "Clothing",    "code": "CLO",  "color": "green" }
  ],
  "permissions": []
}

data rows are flat objects keyed by column name. permissions is an explicit list of extra permission strings to seed beyond the standard CRUD set — SeederGenerator already seeds the base {Module}.list/create/edit/view/delete permissions unconditionally via Helpers::saveModuleCRUDPermissions(), so permissions here is for anything additional (e.g. a custom action's permission).


indexes Array ​

Plain indexes and single-column unique indexes. For a composite (2+ column) unique constraint, use the top-level unique_constraints key instead — see the Top-Level Keys table above; declaring the same column set in both indexes and unique_constraints emits duplicate DDL.

json
"indexes": [
  { "columns": ["category_id", "status_id"], "unique": false }
]

json_rules Object ​

A json column is validated only as array by default — any nested content is saved as-is, with no declarative way to say what it must contain. json_rules declares the complete shape:

json
"json_rules": {
  "params": {
    "rules": {
      "windows": "nullable|array",
      "windows.*.start": "required_with:params.windows.*.end|date_format:H:i",
      "windows.*.end": "required_with:params.windows.*.start|date_format:H:i|after:params.windows.*.start",
      "max_per_hour": "required|integer|min:1"
    },
    "sample": { "max_per_hour": 20, "windows": [ { "start": "08:00", "end": "17:00" } ] }
  }
}

Rules:

  1. Keys in rules are relative to the column — write "windows", not "params.windows". Rule arguments naming another field stay absolute (params.windows.*.end), since that's plain Laravel validator syntax evaluated against the whole request.
  2. A rule is a pipe string (split on |, trimmed, empty pieces dropped) or a list of non-empty strings kept verbatim — use a list when a rule argument itself contains | (e.g. a regex: pattern).

Undeclared nested keys are dropped, not saved

Laravel's own excludeUnvalidatedArrayKeys setting prunes any key of a validated array that isn't itself declared with a rule. Once params.windows is declared, any sibling key of params you did not declare in rules is silently dropped from the saved record — never an error, just gone. This is why sample must be the complete accepted shape, not merely enough to satisfy validation: the generated tests submit it and then assert it round-trips, which would fail the moment a real field got silently pruned.

A nested required rule effectively makes the whole column required in practice — a payload missing that path 422s the same as any other required-field miss, naming the nested key (params.max_per_hour), not the bare column name.

The frontend has no declarative sub-form for json_rules yet (frontend phase) — a JSON column with json_rules still renders as whatever generic control the base column type gets today; the structured sub-form driven by this same declaration is a deferred follow-up.


Complete Minimal Example ​

json
{
  "module_name":    "Products",
  "module_type":    "Custom",
  "table_name":     "products",
  "id_type":        "uuid",
  "columns": [
    {
      "name": "name",
      "type": "string",
      "nullable": false,
      "unique": false,
      "featureSelections": {
        "backend":  { "create": true, "list": true, "view": true, "edit": true, "delete": false },
        "frontend": { "create": true, "list": true, "view": true, "edit": true, "delete": false }
      }
    }
  ],
  "features": {
    "backend": {
      "list":   { "endpoint": { "method": "GET",    "path": "/products",       "permission": "Products.list"   } },
      "create": { "endpoint": { "method": "POST",   "path": "/products",       "permission": "Products.create" } },
      "view":   { "endpoint": { "method": "GET",    "path": "/products/{uuid}", "permission": "Products.view"   } },
      "edit":   { "endpoint": { "method": "PUT",    "path": "/products/{uuid}", "permission": "Products.edit"   } },
      "delete": { "endpoint": { "method": "DELETE", "path": "/products/{uuid}", "permission": "Products.delete" } }
    },
    "frontend": {
      "list":   { "primaryField": "name", "fields": [] },
      "create": { "fields": [] },
      "view":   { "titleData": "name", "fields": [] },
      "edit":   { "fields": [] },
      "delete": { "fields": [] }
    },
    "mobile_app": { "enabled": false }
  },
  "menu_config": { "section": "main", "icon": "Package" }
}

A backend-only module adds "enabled": false to the frontend block above (features.frontend.enabled, default true) — see features.frontend.enabled.

A complete, annotated config covering every top-level key — examples/module-config-full.json — lives in this repository's examples/ directory (not docs/examples/). It is a static reference file, not something actually generated and verified end-to-end — for that, see Examples instead, a set of worked, task-oriented recipes each pointing at a real config that was actually run through the generator and checked against its output.

Released under the Apache-2.0 License.