Skip to content

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:

  1. bin/magento setup:upgrade
  2. bin/magento setup:di:compile
  3. bin/magento cache:flush
  4. 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 of nebula:ui:transform
<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 to grid.collection and add config.collection naming a collection alias registered on CollectionRegistry. For a form, set it to a DataProviderRegistry alias such as vendor_module_thing.form. The original Magento data provider class is preserved as dataSource.config.originalUiDataProvider for reference; config is free-form so it lints either way, but delete that key once you have ported or replaced the provider.
  • Option source aliases. Every filterOptionsSource on a grid column and every optionsSource on 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 in FilterOptionSourceRegistry or OptionSourceRegistry.
  • Mass actions, grids only. The draft writes the array at the top level and sets settings.massActions to true. Move the array into settings.massActions, which accepts false or an array and never true, and give every entry the id the 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 _meta block lints as it stands. The form schema has no such allowance, so delete _meta from 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

bin/magento nebula:generate:schema-docs

Renders the JSON schemas as Markdown, for a module's own documentation or for review.