Skip to content

Grid examples

Eleven complete grid definitions, each trimmed from one that ships with Nebula. Every key used here is a key the PHP engine reads, and every file on this page validates against grid.schema.json.

Each example keeps the id of the file it came from, because that is what the layout XML mounts and what an overlay merges onto. Copying one of these into your own module under the same file name would make a second base for a grid that already has one, so to change a shipped grid write an overlay instead: see Extending a shipped grid. Copy a whole file only when you are building a grid of your own, and give it your own id.

Where an example declares a key the shipped file does not, the Keys worth noticing list says so. Nothing here is invented: every key is read by the engine, and every value is the shape the engine expects.

For what each key means, see the Grid reference.

A catalog listing with images, a price range and a picker

The product grid is the one that exercises the most column types at once. It draws a thumbnail, formats money with price, filters categories through a searchable picker instead of a dropdown that would have to render every category into the page, and paints the status as an inline badge. It also shows the add button in its dropdown form, where {{defaultAttributeSet}} is replaced with the store's default attribute set id before render.

Nebula_Theme/view/adminhtml/grid/product_listing.json
{
  "id": "product_listing",
  "acl": "Magento_Catalog::products",
  "dataSource": {
    "provider": "product.grid",
    "config": {
      "attributes": ["name", "sku", "price", "status", "thumbnail"]
    }
  },
  "settings": {
    "rowClick": "edit",
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "entity_id", "direction": "desc"},
    "massActions": false,
    "cardView": {
      "breakpoint": "md",
      "primary": "name",
      "badge": "status",
      "secondary": ["sku", "price"]
    },
    "addButton": {
      "label": "Add Product",
      "options": [
        {"label": "Simple Product", "url": "catalog/product/new/set/{{defaultAttributeSet}}/type/simple"},
        {"label": "Configurable Product", "url": "catalog/product/new/set/{{defaultAttributeSet}}/type/configurable"}
      ]
    }
  },
  "columns": {
    "entity_id": {
      "label": "ID",
      "type": "text",
      "sortable": true,
      "filter": false,
      "hideBelow": "md",
      "position": 10
    },
    "thumbnail": {
      "label": "Image",
      "type": "thumbnail",
      "position": 20
    },
    "name": {
      "label": "Name",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 30
    },
    "category_id": {
      "label": "Categories",
      "type": "text",
      "sortable": false,
      "filter": "picker",
      "filterSource": "catalog_category",
      "hideable": false,
      "emptyText": "Not assigned",
      "position": 45
    },
    "sku": {
      "label": "SKU",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 50
    },
    "price": {
      "label": "Price",
      "type": "price",
      "sortable": true,
      "filter": "range",
      "position": 60
    },
    "qty": {
      "label": "Quantity",
      "type": "text",
      "sortable": true,
      "filter": "range",
      "hideBelow": "lg",
      "position": 70
    },
    "status": {
      "label": "Status",
      "type": "badge",
      "sortable": true,
      "filter": "select",
      "filterOptions": {"1": "Enabled", "2": "Disabled"},
      "options": {
        "1": {"label": "Enabled", "class": "success"},
        "2": {"label": "Disabled", "class": "neutral"}
      },
      "position": 80
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "edit", "label": "Edit", "url": "catalog/product/edit/id/{{entity_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • dataSource.config.attributes is read only by the product provider. It names the EAV attributes to select, so a column whose attribute is missing from that list renders empty.
  • filter: "picker" must be paired with filterSource. The popover is served by nebula/selection/search, guarded by the selection source's own ACL, so the option list never has to be rendered into the page. The schema enforces the pairing.
  • emptyText is substituted into the row before the cell renderer runs, so a product with no category shows "Not assigned" escaped and wrapped like any other value. Keep it on text-like columns; a link column would try to build a URL from it.
  • hideable: false pins the category column out of the toolbar's column picker. hideBelow is the responsive counterpart: the column drops out below that breakpoint on its own.
  • A badge column that also filters needs both maps. options carries the label and the chip colour for the cell, filterOptions carries the label for the dropdown.
  • cardView and hideBelow are not in the shipped file, which relies on the inferred card layout. Declaring them makes the phone layout a decision rather than a guess.

Source: Nebula_Theme/view/adminhtml/grid/product_listing.json.

An order listing that opens the order

Orders are read-only in the grid and every row leads somewhere, so this grid sets rowUrl: true to say out loud that a click borrows the row's own View action. It sorts newest first, takes its store and status options from registered sources rather than from a map in the file, and gates the add button behind a second ACL because listing orders and creating one are different permissions.

Nebula_Theme/view/adminhtml/grid/sales_order_grid.json
{
  "id": "sales_order_grid",
  "acl": "Magento_Sales::sales_order",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "sales_order.collection"
    }
  },
  "settings": {
    "rowUrl": true,
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "entity_id", "direction": "desc"},
    "massActions": false,
    "addButton": {
      "label": "Create New Order",
      "url": "sales/order_create/start",
      "acl": "Magento_Sales::create"
    }
  },
  "columns": {
    "increment_id": {
      "label": "Order #",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 20
    },
    "store_id": {
      "label": "Store",
      "type": "select",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.store.system",
      "hideBelow": "lg",
      "position": 25
    },
    "created_at": {
      "label": "Purchase Date",
      "type": "date",
      "format": "M j, Y g:i A",
      "sortable": true,
      "filter": "date",
      "position": 30
    },
    "billing_name": {
      "label": "Bill-to Name",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 40
    },
    "grand_total": {
      "label": "Grand Total",
      "type": "price",
      "sortable": true,
      "filter": "range",
      "position": 70
    },
    "status": {
      "label": "Status",
      "renderer": "snippet.status_badge",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.sales.order.status",
      "position": 80
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "view", "label": "View", "url": "sales/order/view/order_id/{{entity_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • rowUrl: true is the default said out loud. The engine borrows the first row action that is a GET, carries no confirmation, and opens a record, which here is View.
  • format is a plain PHP date format. Without it a date column renders M j, Y, and an order placed at 23:40 is indistinguishable from one placed at 00:10 the same morning. The shipped file leaves the default; this one does not.
  • filterOptionsSource resolves to a value-to-label map server side. Labels that come from a source are treated as merchant data and are not passed through the translator, unlike labels written into the file.
  • A column may declare renderer or type, never both. snippet.status_badge renders the cell; the select filter under it still works, because filtering is driven by filter and the options, not by the renderer.
  • addButton.acl is opt-in and additive. The grid's own acl already authorized the page, so this one only hides the button from an admin who may read orders but not create them.
  • price columns are formatted through PriceCurrencyInterface in the store's currency, so the stored number needs no formatting of its own.

Source: Nebula_Theme/view/adminhtml/grid/sales_order_grid.json.

CMS pages with a quick-edit drawer

editable turns the grid into something an admin can change without leaving it. The drawer is a real form: it loads the record through the repository, shows the editable columns plus any extraFields, and saves as a partial merge, so only the posted keys change.

Nebula_Theme/view/adminhtml/grid/cms_page_listing.json
{
  "id": "cms_page_listing",
  "acl": "Magento_Cms::page",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "cms_page.collection"
    }
  },
  "settings": {
    "pageSize": 20,
    "pageSizes": [10, 20, 50],
    "defaultSort": {"field": "page_id", "direction": "asc"},
    "addButton": {"label": "Add New Page", "url": "cms/page/new"},
    "massActions": false
  },
  "editable": {
    "repository": "Magento\\Cms\\Api\\PageRepositoryInterface",
    "identifierField": "page_id",
    "acl": "Magento_Cms::save",
    "extraFields": {
      "content_heading": {"type": "text", "label": "Content Heading", "position": 10},
      "meta_title": {"type": "text", "label": "Meta Title", "maxLength": 255, "position": 20},
      "meta_description": {"type": "textarea", "label": "Meta Description", "position": 30}
    }
  },
  "columns": {
    "page_id": {
      "label": "ID",
      "type": "text",
      "sortable": true,
      "filter": false,
      "position": 10
    },
    "title": {
      "label": "Title",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 20,
      "editable": {"required": true, "maxLength": 255}
    },
    "identifier": {
      "label": "URL Key",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 30,
      "editable": {"required": true, "maxLength": 100}
    },
    "is_active": {
      "label": "Status",
      "renderer": "snippet.status_badge",
      "sortable": true,
      "filter": "select",
      "position": 40,
      "editable": {"options": {"1": "Enabled", "0": "Disabled"}}
    },
    "creation_time": {
      "label": "Created",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "hiddenByDefault": true,
      "position": 50
    },
    "update_time": {
      "label": "Modified",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "position": 60
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "edit", "label": "Edit", "url": "cms/page/edit/page_id/{{page_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • editable.acl is the write permission and is never defaulted to the grid's read acl. Magento separates Magento_Cms::page from Magento_Cms::save and so does this. Omit it and the grid silently becomes read-only, with the reason in the log.
  • editable.repository must expose getById() and save(), and getById() must return a model with a data bag. For an entity with no such repository use model plus resource instead, the way the search term listing does.
  • A column with editable.options renders as a select in the drawer whatever its display type, so a status painted by a badge snippet is still editable. Anything else must be a text column, and the identifierField is never editable.
  • extraFields are fields the drawer shows that the grid does not display. They take the same shape as a form field, so any of the form field types works, and they join the editable columns in the save whitelist.
  • hiddenByDefault starts a column off screen while leaving it declared, filterable and listed in the column picker. An admin's own choice wins from then on.
  • extraFields is the one addition here; the shipped file has the editable block without it.

Source: Nebula_Theme/view/adminhtml/grid/cms_page_listing.json.

A grid scoped to one parent record

A grid on a customer's edit page must show that customer's orders and nothing else. dataSource.config.scope binds the collection to one parent: it filters field by the value of the request parameter param, and when the request carries no value the grid renders empty rather than every row in the table.

Nebula_Theme/view/adminhtml/grid/customer_orders_grid.json
{
  "id": "customer_orders_grid",
  "acl": "Magento_Sales::sales_order",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "sales_order.collection",
      "scope": {"field": "customer_id", "param": "customer_id"}
    }
  },
  "settings": {
    "rowUrl": "sales/order/view/order_id/{{entity_id}}",
    "pageSize": 10,
    "pageSizes": [10, 20, 50],
    "defaultSort": {"field": "created_at", "direction": "desc"},
    "massActions": false,
    "emptyState": {
      "title": "This customer has no orders yet",
      "description": "Orders placed as a guest with the same email address are not listed here, because they carry no customer id."
    }
  },
  "columns": {
    "increment_id": {
      "label": "Order #",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 10
    },
    "created_at": {
      "label": "Purchase Date",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "position": 20
    },
    "status": {
      "label": "Status",
      "renderer": "snippet.status_badge",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.sales.order.status",
      "position": 40
    },
    "grand_total": {
      "label": "Grand Total",
      "type": "price",
      "sortable": true,
      "filter": "range",
      "position": 50
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "view", "label": "View", "url": "sales/order/view/order_id/{{entity_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • scope fails closed. A missing parameter yields an empty grid, not the whole table, which is the only safe default for a grid embedded in someone else's page.
  • The scope parameter is carried on every link the grid renders and on the export URL, so paging, sorting and exporting stay inside the record.
  • rowUrl as a string is the explicit form: the route template is interpolated with {{field}} placeholders from the row. Use it when the destination is not the row's own opening action, or when the actions column holds nothing the engine may borrow.
  • pageSize is lower than a full-page grid's because the grid shares a page with other panels. pageSizes still lists the larger choices for an admin who wants them.
  • emptyState is worth writing wherever emptiness has a cause the filters do not explain. The default copy tells the reader to adjust filters, which here would be wrong advice. The shipped file leaves the default; this one overrides it.

Source: Nebula_Theme/view/adminhtml/grid/customer_orders_grid.json.

The grid the generator writes

This is close to what bin/magento nebula:crud:generate produces, so it is the smallest definition with everything a CRUD screen needs: a range filter on the numeric id, a boolean column, a mass delete, and a row Delete that posts instead of navigating.

Nebula_CrudFixture/view/adminhtml/grid/cms_block_listing.json
{
  "id": "cms_block_listing",
  "acl": "Nebula_CrudFixture::cmsblock",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "cms_block.collection"
    }
  },
  "settings": {
    "pageSize": 20,
    "pageSizes": [10, 20, 50],
    "defaultSort": {"field": "block_id", "direction": "asc"},
    "addButton": {"label": "Add New Cms Block", "url": "nebulacrudfixture/cmsblock/new"},
    "massActionIdField": "block_id",
    "massActions": [
      {
        "id": "delete",
        "label": "Delete",
        "url": "nebulacrudfixture/cmsblock/massDelete",
        "confirm": "Delete selected?"
      }
    ]
  },
  "columns": {
    "block_id": {
      "label": "Entity ID",
      "type": "text",
      "sortable": true,
      "filter": "range",
      "position": 10
    },
    "title": {
      "label": "Block Title",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 20
    },
    "identifier": {
      "label": "Block String Identifier",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 30
    },
    "creation_time": {
      "label": "Block Creation Time",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "hiddenByDefault": true,
      "position": 40
    },
    "is_active": {
      "label": "Is Block Active",
      "type": "boolean",
      "sortable": true,
      "filter": "select",
      "filterOptions": {"1": "Yes", "0": "No"},
      "position": 60
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {
          "id": "edit",
          "label": "Edit",
          "url": "nebulacrudfixture/cmsblock/edit/block_id/{{block_id}}"
        },
        {
          "id": "delete",
          "label": "Delete",
          "url": "nebulacrudfixture/cmsblock/delete/block_id/{{block_id}}",
          "method": "post",
          "confirm": "Are you sure?"
        }
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • method: "post" renders a form-key protected POST instead of a link, which is what a controller implementing HttpPostActionInterface requires. Pair it with confirm for anything destructive.
  • A POST action and a confirmed action are both skipped when the engine looks for a row URL to borrow, so this grid's row click lands on Edit and never on Delete. No rowUrl key is needed to get that.
  • Give every row action an id. A list whose items carry ids is merged by id when another module overlays the grid; a list without ids is replaced wholesale, and an overlay that meant to add one action silently deletes the rest.
  • boolean is a fixed Yes/No chip pair with nothing to declare. Its select filter still needs filterOptions, because the filter is a dropdown of stored values, not of rendered chips.
  • filter: "range" on a numeric id gives a From/To pair. The provider reads block_id_from and block_id_to and builds one condition.
  • massActionIdField names the row field each checkbox submits. Without it the checkboxes submit id, which this table does not have.

Source: Nebula_CrudFixture/view/adminhtml/grid/cms_block_listing.json. See also Your first grid.

Review moderation: mass actions that ask a question

Reviews are joined across three tables, so almost every column names its own DB expressions, and the Update Status action needs a value before it can run. additional collects that value in the confirmation modal and posts it alongside the selected ids.

Nebula_Theme/view/adminhtml/grid/review_product.json
{
  "id": "review_product",
  "acl": "Magento_Review::reviews_all",
  "dataSource": {
    "provider": "grid.review.collection",
    "config": {
      "collection": "review_product.collection"
    }
  },
  "settings": {
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "created_at", "direction": "desc"},
    "massActionIdField": "review_id",
    "massActions": [
      {
        "id": "delete",
        "label": "Delete",
        "field": "reviews",
        "url": "review/product/massDelete/ret/index",
        "confirm": "Are you sure you want to delete the selected reviews?"
      },
      {
        "id": "update_status",
        "label": "Update Status",
        "field": "reviews",
        "url": "review/product/massUpdateStatus/ret/index",
        "additional": [
          {
            "name": "status",
            "label": "Status",
            "type": "select",
            "optionsSource": "magento.review.status"
          }
        ]
      }
    ]
  },
  "columns": {
    "review_id": {
      "label": "ID",
      "type": "text",
      "sortable": true,
      "filter": false,
      "hideBelow": "md",
      "index": "review_id",
      "sortIndex": "rt.review_id",
      "filter_index": "rt.review_id",
      "position": 10
    },
    "created_at": {
      "label": "Created",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "hideBelow": "lg",
      "index": "review_created_at",
      "sortIndex": "rt.created_at",
      "filter_index": "rt.created_at",
      "position": 20
    },
    "status": {
      "label": "Status",
      "type": "badge",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.review.status",
      "index": "status_id",
      "sortIndex": "rt.status_id",
      "filter_index": "rt.status_id",
      "options": {
        "1": {"label": "Approved", "class": "success"},
        "2": {"label": "Pending", "class": "warning"},
        "3": {"label": "Not Approved", "class": "error"}
      },
      "position": 30
    },
    "title": {
      "label": "Title",
      "type": "text",
      "sortable": true,
      "filter": false,
      "searchable": true,
      "index": "title",
      "sortIndex": "rdt.title",
      "filter_index": "rdt.title",
      "position": 40
    },
    "visible_in": {
      "label": "Visibility",
      "type": "text",
      "sortable": false,
      "filter": "select",
      "filterOptionsSource": "nebula.store.view",
      "index": "visible_in",
      "filter_index": "stores",
      "hideBelow": "xl",
      "position": 82
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "edit", "label": "Edit", "url": "review/product/edit/id/{{review_id}}/ret/index"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • The three index keys are three different jobs. index is the row field the cell reads, sortIndex is the expression in the ORDER BY, and filter_index is the expression in the WHERE. A joined column needs the qualifier in the last two and the collection's alias in the first, which is why created_at reads review_created_at but sorts on rt.created_at.
  • field renames the POST array the checkboxes submit. It defaults to ids; this vendor controller reads reviews, so the definition says so rather than wrapping the controller.
  • additional renders inside the confirmation modal, before the post. select is the only widget it renders, and each entry takes either options inline or optionsSource, never both.
  • A mass action without confirm still asks, using a generic question built from the label. Write confirm where the generic wording would understate the consequence.
  • hideBelow here is a priority order rather than a hide list. ID drops first, then the date, and the long text columns drop before the product name does.
  • A badge may take its filter labels from a source while keeping its own colour map, which is what filterOptionsSource plus options does on the status column.

Source: Nebula_Theme/view/adminhtml/grid/review_product.json.

Mass actions for a stock ui_component controller

Some Magento mass-action controllers do not read a list of ids at all. They resolve the selection through Magento\Ui\Component\MassAction\Filter, which needs the name of a ui_component. For those, post under the field selected and name the component in namespace; the grid then submits what the vendor controller expects, unchanged.

Nebula_Theme/view/adminhtml/grid/search_synonyms_grid.json
{
  "id": "search_synonyms_grid",
  "acl": "Magento_Search::synonyms",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "search_synonyms.collection"
    }
  },
  "settings": {
    "rowUrl": true,
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "group_id", "direction": "asc"},
    "addButton": {"label": "New Synonym Group", "url": "search/synonyms/new"},
    "massActionIdField": "group_id",
    "massActions": [
      {
        "id": "delete",
        "label": "Delete",
        "field": "selected",
        "namespace": "search_synonyms_grid",
        "url": "search/synonyms/massDelete",
        "confirm": "Are you sure you want to delete the selected synonym groups?"
      }
    ]
  },
  "columns": {
    "group_id": {
      "label": "ID",
      "type": "text",
      "sortable": true,
      "filter": false,
      "position": 10
    },
    "synonyms": {
      "label": "Synonyms",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "position": 20
    },
    "store_id": {
      "label": "Store View",
      "type": "select",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.search.store.options",
      "position": 40
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "edit", "label": "Edit", "url": "search/synonyms/edit/group_id/{{group_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • namespace is the stock ui_component name, not the Nebula grid id, even when the two read the same. It is the string the vendor controller's filter looks up.
  • namespace is only useful with "field": "selected". Together they reproduce exactly what the stock listing posts, which is the point: no controller is replaced, no plugin is added.
  • Reach for this only when the controller demands it. A controller that reads a plain array of ids needs neither key, and massActionIdField alone is enough.
  • filterOptionsSource is resolved once per render by ColumnLoader, then handed to the filter template as a plain map, so a source that hits the database costs one call per page and not one per row.
  • rowUrl: true with a single Edit action is the ordinary case. The grid has no quick-edit drawer, so a row click opens the edit page.

Source: Nebula_Theme/view/adminhtml/grid/search_synonyms_grid.json.

Cache management: an in-memory list

Not every grid is backed by a database table. The cache and index management grids wrap Magento's in-memory list collections, which hold a fixed set of rows built in PHP. There is nothing to sort or filter on, so every column turns both off, and the toolbar grows the two flush buttons that belong to the screen rather than to a row.

Nebula_System/view/adminhtml/grid/cache_management.json
{
  "id": "cache_management",
  "acl": "Magento_Backend::cache",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "cache_types.collection"
    }
  },
  "settings": {
    "pageSize": 50,
    "pageSizes": [20, 50, 100],
    "massActions": [
      {
        "id": "enable",
        "label": "Enable",
        "field": "types",
        "url": "adminhtml/cache/massEnable",
        "developerOnly": true
      },
      {
        "id": "disable",
        "label": "Disable",
        "field": "types",
        "url": "adminhtml/cache/massDisable",
        "developerOnly": true
      },
      {
        "id": "refresh",
        "label": "Refresh",
        "field": "types",
        "url": "adminhtml/cache/massRefresh"
      }
    ],
    "buttons": [
      {
        "label": "Flush Magento Cache",
        "url": "adminhtml/cache/flushSystem",
        "variant": "secondary"
      },
      {
        "label": "Flush Cache Storage",
        "url": "adminhtml/cache/flushAll",
        "variant": "danger",
        "confirm": "The cache storage may contain additional data. Are you sure that you want to flush it?"
      }
    ]
  },
  "columns": {
    "cache_type": {
      "label": "Cache Type",
      "type": "text",
      "sortable": false,
      "filter": false,
      "index": "cache_type",
      "position": 10
    },
    "description": {
      "label": "Description",
      "type": "text",
      "sortable": false,
      "filter": false,
      "index": "description",
      "hideBelow": "lg",
      "position": 20
    },
    "tags": {
      "label": "Associated Tags",
      "type": "text",
      "sortable": false,
      "filter": false,
      "index": "tags",
      "hideBelow": "xl",
      "position": 30
    },
    "status": {
      "label": "Status",
      "type": "badge",
      "sortable": false,
      "filter": false,
      "index": "status",
      "options": {
        "1": {"label": "Enabled", "class": "success"},
        "0": {"label": "Disabled", "class": "error"}
      },
      "position": 40
    }
  }
}

Keys worth noticing

  • grid.collection wraps anything extending Magento\Framework\Data\Collection, not only database collections, so an in-memory list needs no provider of its own.
  • sortable: false and filter: false on every column are deliberate. An in-memory collection has no select to sort or filter, and a header that looked clickable but did nothing would be worse than a plain one.
  • developerOnly drops the action entirely outside developer mode. Keep at least one action without the flag, or a selection in production has nothing it can do.
  • buttons are toolbar buttons, not row actions. They navigate to a route, take a variant of primary, secondary or danger, and take an optional confirm.
  • There is no addButton and no defaultSort, because there is nothing to add and nothing to order. Every key here is optional; a grid declares only what it has.
  • No massActionIdField, so each checkbox submits the row's id field, which is what the cache type list calls its key.

Source: Nebula_System/view/adminhtml/grid/cache_management.json. The index management grid is the same shape with indexer_list.collection and massActionIdField: "indexer_id".

A report: calendar dates and no add button

A report lists rows that already happened. Nothing is created from it, so it has no add button and no mass actions, and every column is a filter on a large result set. The report_date column is the one that matters: it is a calendar date stored as a datetime at midnight, and converting it to the store timezone would show the day before, so it opts out with timezone: false.

Nebula_Reports/view/adminhtml/grid/paypal_settlement_report.json
{
  "id": "paypal_settlement_report",
  "acl": "Magento_Paypal::paypal_settlement_reports",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "paypal_settlement.collection"
    }
  },
  "settings": {
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "report_date", "direction": "desc"},
    "massActions": false
  },
  "columns": {
    "report_date": {
      "label": "Report Date",
      "type": "date",
      "sortable": true,
      "filter": "date",
      "timezone": false,
      "index": "report_date",
      "position": 10
    },
    "transaction_id": {
      "label": "Transaction ID",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "index": "transaction_id",
      "position": 30
    },
    "transaction_event_code": {
      "label": "Event",
      "type": "select",
      "sortable": true,
      "filter": "select",
      "filterOptionsSource": "magento.paypal.settlement.event",
      "index": "transaction_event_code",
      "position": 60
    },
    "transaction_initiation_date": {
      "label": "Start Date",
      "type": "date",
      "format": "M j, Y g:i A",
      "sortable": true,
      "filter": "date",
      "index": "transaction_initiation_date",
      "position": 70
    },
    "gross_transaction_amount": {
      "label": "Gross Amount",
      "type": "price",
      "sortable": true,
      "filter": "range",
      "index": "gross_transaction_amount",
      "position": 90
    },
    "fee_amount": {
      "label": "Fee Amount",
      "type": "price",
      "sortable": true,
      "filter": false,
      "index": "fee_amount",
      "hideBelow": "lg",
      "position": 100
    },
    "actions": {
      "label": "",
      "type": "actions",
      "actions": [
        {"id": "view", "label": "View", "url": "paypal/paypal_reports/details/id/{{row_id}}"}
      ],
      "position": 999
    }
  }
}

Keys worth noticing

  • timezone: false keeps the column out of store-timezone conversion, for the displayed value and for the filter bounds alike. A value with no time of day is never converted regardless, so a column stored as a plain date needs no flag.
  • massActions: false is not the same as omitting the key. false removes the row checkboxes and the mass-action bar; omitting it leaves an empty list, which renders the same today but says nothing about intent.
  • pageSizes is worth raising on a report. A report is read in bulk, and the interactive ceiling is 200 rows a page. An export raises the ceiling to 100000 and logs a warning when the result set is larger.
  • filter: "range" on a money column reads gross_transaction_amount_from and gross_transaction_amount_to. The fee column leaves the filter off, because two range pairs over the same rows rarely narrow anything.
  • defaultSort on a report should be the column the reader scans by, which for a settlement report is the most recent date first.
  • There is nothing to add, so there is no addButton and no editable block.

Source: Nebula_Reports/view/adminhtml/grid/paypal_settlement_report.json.

Two things here that no other example shows. A link column turns the cell value into an anchor whose target comes from href, and the columns carry table-qualified index values, because the collection joins the admin user table and an unqualified history_id would be ambiguous in the SQL.

Nebula_System/view/adminhtml/grid/import_history_listing.json
{
  "id": "import_history_listing",
  "acl": "Magento_ImportExport::history",
  "dataSource": {
    "provider": "grid.collection",
    "config": {
      "collection": "import_history.collection"
    }
  },
  "settings": {
    "pageSize": 20,
    "pageSizes": [10, 20, 50, 100],
    "defaultSort": {"field": "history_id", "direction": "desc"},
    "massActions": false
  },
  "columns": {
    "history_id": {
      "label": "ID",
      "type": "text",
      "sortable": true,
      "filter": false,
      "index": "main_table.history_id",
      "position": 10
    },
    "started_at": {
      "label": "Start Date & Time",
      "type": "date",
      "format": "M j, Y g:i A",
      "sortable": true,
      "filter": "date",
      "index": "main_table.started_at",
      "position": 20
    },
    "username": {
      "label": "User",
      "type": "text",
      "sortable": true,
      "filter": "text",
      "searchable": true,
      "index": "link_table.username",
      "position": 30
    },
    "imported_file": {
      "label": "Imported File",
      "type": "link",
      "href": "adminhtml/history/download/filename/{{imported_file}}",
      "sortable": false,
      "filter": false,
      "searchable": true,
      "index": "main_table.imported_file",
      "position": 40
    },
    "error_file": {
      "label": "Error File",
      "type": "link",
      "href": "adminhtml/history/download/filename/{{error_file}}",
      "sortable": false,
      "filter": false,
      "position": 50
    },
    "summary": {
      "label": "Summary",
      "type": "text",
      "sortable": false,
      "filter": false,
      "hideBelow": "lg",
      "position": 70
    }
  }
}

Keys worth noticing

  • href is interpolated with {{field}} placeholders from the row, then built through the URL builder when it names an admin route, so the link carries the secret key. A value that is already absolute, protocol-relative, root-relative or a bare # is passed through untouched.
  • A link cell renders no anchor at all when the value is empty. An import that produced no error file would otherwise get a zero-width link pointing at a download route with no filename, so do not add emptyText to a link column.
  • A table-qualified index is the fix for an ambiguous column in a joined collection. It is used for the displayed value, the filter and the sort.
  • This grid has no actions column. The trailing button group is a convention, not a requirement, and a grid whose only actions are links inside cells does not need one.
  • With no actions column and no rowUrl, the row click has nothing to borrow and the rows are inert. That is correct here: there is no import history detail page.
  • searchable on the file name puts it in the toolbar's free-text box, which is how an admin finds the run that imported one particular file.

Source: Nebula_System/view/adminhtml/grid/import_history_listing.json.

An overlay that adds one column

The smallest useful file on this page. It has no id, so the engine treats it as a patch onto the base definition of the same name and merges it in module load order.

Vendor_Module/view/adminhtml/grid/cms_page_listing.json
{
  "columns": {
    "page_layout": {"label": "Layout", "type": "text", "filter": "text", "position": 35}
  }
}

Keys worth noticing

  • An overlay must omit id. A file that sets one is a base, and two bases for the same grid is a merge conflict waiting to happen.
  • A column in an overlay is a patch, so label and position are only required because this column is new. Changing an existing column takes only the keys that change.
  • The column still needs data. cms_page.collection already selects page_layout; a column the collection does not select renders empty and cannot be filtered.
  • Sequence your module after the one that ships the base grid, or your file may load first and become the base itself.

Source: the pattern is documented in Extending a shipped grid, which covers $remove, merging row actions by id, and the rest.

Custom column renderer

The ten built-in column types are a DI array, not a hard-coded list. A renderer is any class implementing Nebula\Component\Api\V1\ColumnRendererInterface, and the shipped ones all extend AbstractColumnRenderer, which handles escaping and delegates the markup to a .phtml partial. Override getTemplate() to name the partial and toViewData() to shape what the partial receives.

Vendor/Module/Renderer/Column/ProgressRenderer.php
<?php

declare(strict_types=1);

namespace Vendor\Module\Renderer\Column;

use Nebula\Grid\Renderer\Column\AbstractColumnRenderer;

class ProgressRenderer extends AbstractColumnRenderer
{
    public function getComponentName(): string
    {
        return 'nebulaColumn_progress';
    }

    public function getTemplate(): string
    {
        return 'Vendor_Module::column/progress.phtml';
    }

    /**
     * @param array<string, mixed> $column
     * @param array<string, mixed> $item
     * @return array<string, mixed>
     */
    protected function toViewData(array $column, string $key, array $item): array
    {
        $raw = (float) ($item[$key] ?? 0);

        return [
            'column' => $column,
            'key' => $key,
            'item' => $item,
            'value' => $raw,
            'percent' => max(0.0, min(100.0, $raw)),
        ];
    }
}

render() comes from the base class: it calls getTemplate(), and renders that partial with whatever toViewData() returned. Return an empty string from getTemplate() to compose the cell in PHP instead, in which case the base class escapes the raw value for you. getComponentName() names the Alpine component for a column that is enhanced in the browser; the convention is nebulaColumn_<type>.

Bind it under columnRenderers, keyed by the name a column will ask for:

Vendor/Module/etc/di.xml
<type name="Nebula\Component\Model\RendererPool">
    <arguments>
        <argument name="columnRenderers" xsi:type="array">
            <item name="progress" xsi:type="object">Vendor\Module\Renderer\Column\ProgressRenderer</item>
        </argument>
    </arguments>
</type>

The array is additive, so your etc/di.xml adds to the ten built-in entries without touching them. A column then selects the renderer by that key:

{"completion": {"label": "Progress", "type": "progress", "position": 40}}

A custom type is read by the engine and rejected by the schema

RendererPool resolves any bound key, and an unknown type falls back to text rather than failing. But grid.schema.json closes type to the ten built-ins, so a definition using a custom type fails bin/magento nebula:lint, and no flag relaxes that enum. The schema-clean route to a custom cell is a snippet: renderer accepts snippet.<id>, the dispatcher checks it before the type, and the snippet's own JSON names the template, which can be any .phtml with a view model behind it. See Snippets. Reach for a DI-bound renderer when you want a reusable column type in PHP and can live with the lint failure; reach for a snippet when the linter has to pass.

Source: adapted from code/Nebula/Grid/Renderer/Column/BadgeRenderer.php and code/Nebula/Grid/Renderer/Column/LinkRenderer.php, which are the two shipped renderers that override toViewData(). The dispatch order is in code/Nebula/Grid/Renderer/Column/ColumnDispatcher.php, the registry in code/Nebula/Component/Model/RendererPool.php.

Validate before you ship

Run the linter. It validates every grid, form, snippet and component in every enabled module against its Draft 2020-12 schema, and the grid schema is strict: a misspelt key is an error, not a shrug.

bin/magento nebula:lint
bin/magento nebula:lint --path=app/code/Vendor/Module   # one module or one file
bin/magento nebula:lint --fix                           # reformat, alphabetize, auto-position

Every JSON example on this page was validated against code/Nebula/Component/schemas/grid.schema.json with a Draft 2020-12 validator before it was published, so each one is a file you can drop in and lint.

Keys these examples do not use

Three things are worth knowing about but had nowhere honest to appear. The html column type is registered and renders its value unescaped, but no shipped definition uses it, and the only safe source for it is markup the server itself produced. The template column key and a renderer naming a Vendor_Module::path.phtml template are both read by the dispatcher but rejected by the schema. settings.cardView.columns is accepted by the schema and ignored by the engine, which drives the card layout from primary, badge and secondary only. The Grid reference keeps the full list of both mismatches.