Generators¶
Five commands write files for you. Each is documented option by option in the CLI reference; this page says when to reach for which.
A whole CRUD module from a table¶
bin/magento nebula:crud:generate vendor_module_note --module=Vendor_Module \
--menu-parent=Magento_Backend::content --menu-title="Notes" --dry-run
Reads the table from the merged db_schema.xml of every module, and writes a complete module under app/code/<Vendor>/<Module>/: composer.json, registration.php and etc/module.xml; the model, resource model and collection; the API data interface, search results interface and repository interface with implementations; the form data provider; Index, NewAction, Edit, Save, Delete and MassDelete controllers; acl.xml, menu.xml, routes.xml, di.xml with the collection and provider aliases; the grid and form definitions; and the two layout handles.
The <sequence> in etc/module.xml always declares Nebula_Component, Nebula_Grid, Nebula_Form and Nebula_Theme, then appends the module that owns the table when that module was resolved and is not the one being generated. The composer require gains that owner's package only when its name could be read from the owner's own composer.json.
Point --module at a new module
Because the output is a whole module, generate into a module that does not exist yet, such as --module=Acme_BlogAdmin. Aiming --module at the module that declares the table collides with its existing composer.json, registration.php and etc/module.xml, and --force overwrites them. The command detects that case and prints the same advice.
Column types map to field and column types as follows.
| Column | Form field | Grid column | Grid filter |
|---|---|---|---|
| Identity primary key | hidden |
text |
range |
| Non-identity primary key | text, required, disableOnEdit |
text |
range |
created_at, updated_at, any onUpdate="true" column, any column defaulting to CURRENT_TIMESTAMP |
none, excluded from the form | date |
date |
| Foreign key | select |
text |
select |
boolean, tinyint(1), a name starting is_, the name enabled |
toggle |
boolean |
select, with a static Yes/No filterOptions map |
int, smallint, bigint, tinyint |
text |
text |
range |
decimal, float, double |
text |
text |
range |
date |
date |
date |
date |
datetime, timestamp |
datetime |
date |
date |
text, mediumtext, longtext |
textarea |
none | none |
json, blob, mediumblob, longblob, varbinary |
none | none | none |
varchar and anything else string like |
text |
text |
text, searchable when the column is named name, title, sku, code, email, label or identifier |
Adjust with --select, --wysiwyg, --hidden, --readonly, --exclude and --searchable, or edit the generated JSON afterwards.
Only three foreign keys are bound to an option source automatically: store_id to nebula.store.view, website_id to magento.theme.scope.website, and customer_group_id to magento.customer.group. Every other foreign key becomes a select carrying a literal "options": [], an empty dropdown, and the command prints a warning block naming those columns. Finish them by adding an optionsSource alias or a literal options array to view/adminhtml/form/<table>_edit.json, or bind them up front with --select=col:alias.
ALLOWED_FIELDS does not follow the JSON
The Save controller carries a field whitelist, ALLOWED_FIELDS, generated once from the form fields and never read back from the definition. A field you add to view/adminhtml/form/<table>_edit.json later renders and posts, but is silently dropped on save until you add its column name to ALLOWED_FIELDS in Controller/Adminhtml/<Entity>/Save.php. Your first form shows the shape of that controller.
--dry-run runs the whole pipeline and writes nothing. --force overwrites files that exist. --compile runs setup:di:compile when done. With no table argument the command asks its questions interactively.
After it runs¶
The module is written into app/code but is not yet registered in app/etc/config.php. Enable it with bin/magento module:enable Vendor_Module, or let setup:upgrade register it. The command then prints its own four steps:
bin/magento setup:upgradebin/magento setup:di:compilebin/magento cache:flush- Verify the generated grid and form by hand in the admin before shipping.
Step 4 is not optional. The pipeline checks PHP syntax, XML against its XSD and JSON against the Nebula schemas; it never renders anything. Open the grid, open the edit form, and save a record end to end. Fill in an option source for any foreign key the command listed while you are there.
--compile runs setup:di:compile, which compiles enabled modules only. On a module written moments ago that validates the application rather than the new code, so enable the module and run the compile again if you want it to cover what was generated.
A column whose PascalCase name collides with a method on DataObject or AbstractModel, such as data, id, collection, entity_id or cache_tags, gets its KEY constant but no typed getter and setter pair. Reach those values with getData('<column>') and setData('<column>', ...) instead. The command names the affected columns when it runs.
Read the generated definitions against Your first grid and Your first form; they are the same files.
Generated interfaces keep plain Api\
The scaffolding emits your module's interfaces under Vendor\Module\Api\, not Api\V1\. They are your module's contract, not Nebula's, and the versioning policy is yours to set.
A grid or form skeleton¶
bin/magento nebula:generate:grid Vendor_Module vendor_module_note_listing
bin/magento nebula:generate:form Vendor_Module vendor_module_note_edit
Each writes a minimal definition and the layout handle that mounts it into the module's view/adminhtml/ directories. Nothing else: no controller, no provider, no DI. Use them when the model already exists and you want a starting file rather than a blank one.
Definitions from UI Component XML¶
bin/magento nebula:ui:transform vendor/acme/module-blog
bin/magento nebula:ui:transform vendor/acme/module-blog/view/adminhtml/ui_component/acme_blog_post_listing.xml --output-dir=var/drafts/blog
Reads UI Component XML for listings and forms and writes a draft definition and a layout stub per component, by default under var/nebula-ui-transformer/<source name>.
Never aim --output-dir at a live module
Nebula's definition loader scans view/adminhtml/grid/*.json and view/adminhtml/form/*.json in every enabled module, and any definition it can load there suppresses the stock UI Component of the same name. A draft written straight into an enabled module's view/adminhtml therefore takes the stock screen away while being too invalid to replace it, and leaves a stray README.md behind. Write drafts to a scratch directory such as var/drafts/blog, edit them until they lint, then promote them by hand.
What the command writes¶
<output-dir>/
├── grid/<component>.json
├── form/<component>.json
├── unsupported/<component>.json
├── layout/<handle>.xml
└── README.md
The component name is the XML file's basename, so acme_blog_post_listing.xml becomes acme_blog_post_listing.json. Renaming the file breaks the name match that removes the stock UI Component. A root node that is neither <listing> nor <form> lands under unsupported/ holding nothing but a _meta block. The generated README.md records the source path, every component and every warning.
A layout stub appears only when the source module ships a layout XML that references the <uiComponent> by name. The stub reuses that source handle's filename and declares Nebula\Grid\Block\Grid with a grid_id argument, or Nebula\Form\Block\Form with a form_id argument, named nebula.<component> inside referenceContainer name="content". With no such reference you get a warning and no stub.
What converts¶
Listing conversion is narrow.
| Source | Draft |
|---|---|
| Any column | type: text, unless the dataType is date or the component or class name contains Date, which gives type: date |
Filter text |
filter: text, plus searchable: true |
Filter textRange |
filter: range |
Filter dateRange |
filter: date |
Filter select |
filter: select |
| Any other filter | dropped, with no warning |
| Paging | never read from the source; every draft gets pageSize: 20 and pageSizes: [10, 20, 50] |
Mass action declaring <url path="..."/> |
an entry carrying label and url |
| Mass action without one | skipped, with a warning |
actionsColumn |
type: actions with an empty actions: [] for you to fill in |
selectionsColumn |
dropped, because Nebula supplies its own |
Select, boolean, thumbnail, link, html and price columns all arrive as text and must be retyped by hand.
Form fields map by formElement.
formElement |
Draft type |
|---|---|
input |
number when the dataType is number or int, otherwise text |
textarea |
textarea |
select |
select |
multiselect |
multiselect |
wysiwyg |
wysiwyg |
date |
date |
checkbox |
toggle when prefer is toggle or the dataType is boolean, otherwise checkbox |
| anything else | date when the dataType is date, otherwise text |
A field carrying visible=false becomes hidden whatever its formElement. There is no email, colorPicker, file, image, password or Page Builder mapping. The field key is the source dataScope when one is declared, and the field's name otherwise. Only direct <fieldset> children of <form> are walked. Only six validation rules map: required-entry, validate-email, min_text_length, max_text_length, min and max. Every other rule is flagged for manual review. A product or category form, recognised by its component name or by a catalog/product/save or catalog/category/save submit URL, still emits a plain simple-form draft plus an EAV warning rather than an EAV definition.
Nested structures vanish, some of them silently
An insertListing, insertForm or modal is dropped from the draft with no warning at all. A dynamicRows block is worse than dropped: every <field> inside its record template is flattened into the parent fieldset as a plain single-valued field, so the draft looks complete while the repeating structure is gone. Diff the draft's field list against the source XML for any form that uses dynamic rows, and delete the flattened fields before shipping.
The transformer writes no markers into the JSON. Unsupported input surfaces only as warning strings, printed per component and repeated in _meta.warnings and in README.md. The command exits 0 whether or not it warned. The full set is:
Unsupported UI component root node: <root>No <columns> node found.Actions column "x" requires manual action mapping.Mass action "x" uses a callback or unsupported config and requires manual migration.Validation rule "x" requires manual review.Fieldset "x" has no transformable <field> nodes.No direct <fieldset> nodes were found. Nested container-only forms are not yet supported.Potential EAV form detected.No layout XML reference to this uiComponent was found in the source module.
One file at a time, with no merge
The transformer parses a single XML file. Magento's ui_component merge, where a second module adds columns or fields to a component another module declared, is not applied, and nor is the extends attribute. Converting cms_page_listing.xml from Magento_Cms yields only what that one file declares, so columns contributed by other modules are missing from the draft, and two modules declaring the same component name produce two unmerged drafts. For the assembled picture, capture it through the UI Bridge, which converts the config after Magento has built it.
Before the draft will lint¶
A freshly written draft never passes nebula:lint as it stands. Every one needs these edits.
dataSource.provider. The draft sets"provider": "TODO_NEBULA_PROVIDER_CLASS", a deliberate placeholder that fails schema validation until you replace it. For a grid, set it togrid.collectionand addconfig.collectionnaming a collection alias registered onCollectionRegistry. For a form, set it to aDataProviderRegistryalias such asvendor_module_thing.form. The original Magento data provider class is preserved asdataSource.config.originalUiDataProviderfor reference;configis free-form so it lints either way, but delete that key once you have ported or replaced the provider.- Option source aliases. Every
filterOptionsSourceon a grid column and everyoptionsSourceon a form field holds the Magento source class name copied out of the XML. Both schemas reject FQCNs and require a lowercase dotted alias registered inFilterOptionSourceRegistryorOptionSourceRegistry. - Mass actions, grids only. The draft writes the array at the top level and sets
settings.massActionstotrue. Move the array intosettings.massActions, which acceptsfalseor an array and nevertrue, and give every entry theidthe draft omits. An id matches^[a-z][a-z0-9_]*$. _meta, forms only. The grid schema accepts any top-level key beginning with_, so a grid draft's_metablock lints as it stands. The form schema has no such allowance, so delete_metafrom every form draft.
Then move the JSON into a module that sequences after the module that owns the component, keep the file named after the component so the UI Component removal takes effect, add an acl, run nebula:lint, and test the page.
For converting at runtime without committing a definition, see UI Bridge.
Schema documentation¶
Renders the JSON schemas as Markdown, for a module's own documentation or for review.