Panels and Tabs
Martis supports three layout mechanisms for organising fields on forms and detail pages: Panels, Tabs, and Sections.
- Panel — a visual grouping with a title bar, optional description, collapsible, and a Show more limit.
- TabGroup / Tab — navigable sections; each Tab holds fields and/or Panels.
- Section — a configurable CSS grid for multi-column form layouts with
Field::span(). Covered in Grid Layout.
Panels
A Panel groups related fields into a visual section with a title, a differentiated
background and a horizontal separator. It can be collapsed by the user and supports
a limit of visible fields before a "Show more" button.
API
use Martis\Layout\Panel; Panel::make('Panel Title', [ // fields...])->description('Descriptive text') // Martis extension — subtitle below the title->collapsible() // optional — allows the user to collapse the panel->collapsedByDefault() // optional — starts collapsed (implies collapsible)->limit(int $n) // optional — only shows the first N fieldsMartis extension:
->description()adds a subtitle below the Panel title.
Help Text on Fields
Fields inside Panels support ->help() to display contextual help below the input. Supports inline HTML:
Panel::make('Security', [ Text::make('password') ->required() ->help('Minimum 8 characters. Use a <strong>strong password</strong>.'),])Examples
Basic Panel
The simplest case: groups fields in a visual container with a title. The user cannot collapse it.
Panel::make('Publication', [ Select::make('status') ->options(['draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived']) ->required(), DateTime::make('published_at', 'Published At') ->nullable(),]),Collapsible Panel
The user can click the header to collapse or expand the panel. Starts expanded by default.
Panel::make('Author & Category', [ BelongsTo::make('category') ->relatedResource('categories') ->nullable(), BelongsTo::make('user', 'Author') ->relatedResource('users') ->nullable(),])->collapsible(),Panel collapsedByDefault
Starts collapsed. Useful for advanced or rarely edited fields — they stay out of the way without disappearing entirely.
Panel::make('Advanced Content', [ Markdown::make('excerpt', 'Excerpt')->nullable(), Textarea::make('body')->nullable(),])->collapsedByDefault(),Panel with limit
Shows only the first N fields. The rest are tucked behind a "Show more / Show less" button. Ideal when a panel has many fields and we want to reduce the initial scroll.
Panel::make('Tags & Labels', [ Tag::make('tags', 'Tags') ->relatedResource('tags'), MultiSelect::make('labels', 'Labels') ->options(['featured' => 'Featured', 'trending' => 'Trending', 'exclusive' => 'Exclusive']), Text::make('source_url', 'Source URL') ->nullable(),])->limit(1), // only shows the first field initiallyWhere to use Panels
Panels may appear in:
fieldsForCreate()fieldsForUpdate()fieldsForDetail()- Inside Tabs (see the section below)
They do not appear in fields() (index) — in that context fields are always flattened.
Tabs
Tabs organise fields and panels into navigable sections. They are useful for complex resources with many fields, allowing different themes (General, Content, Organisation, Advanced) to be grouped without overloading the form.
Structure
TabGroup ← top-level container (implements LayoutContract) └── Tab ← individual tab ├── Field ← field directly inside the tab └── Panel ← panel nested inside the tabAPI
use Martis\Layout\Tab;use Martis\Layout\TabGroup; TabGroup::make([ Tab::make('Tab Name', [ // fields and/or panels... ]), Tab::make('Another Tab', [ // fields and/or panels... ]),])Examples
Simple Tabs (fields only)
TabGroup::make([ Tab::make('General', [ Text::make('title')->required(), Select::make('status')->options(['draft' => 'Draft', 'published' => 'Published']), DateTime::make('published_at')->nullable(), ]), Tab::make('Content', [ Markdown::make('excerpt')->nullable(), Textarea::make('body')->nullable(), ]),]),Tabs with Nested Panels
Each Tab can contain one or more Panel. Panels inside tabs work
independently — a collapsible panel inside one tab can be collapsed without
affecting the other tabs.
TabGroup::make([ Tab::make('Organisation', [ Panel::make('Relations', [ BelongsTo::make('category')->relatedResource('categories'), BelongsTo::make('user', 'Author')->relatedResource('users'), ])->collapsible(), Tag::make('tags', 'Tags')->relatedResource('tags'), ]), Tab::make('Advanced', [ Panel::make('SEO & References', [ Text::make('source_url', 'Source URL')->nullable(), ])->collapsedByDefault(), ]),]),Full TabGroup (real-world example)
public function fieldsForUpdate(Request $request): array{ return [ TabGroup::make([ Tab::make('General', [ Text::make('title')->required(), Badge::make('status')->addTypes([ 'published' => 'success', 'draft' => 'warning', 'archived' => 'danger', ]), DateTime::make('published_at', 'Published At')->nullable(), ]), Tab::make('Content', [ Markdown::make('excerpt', 'Excerpt')->nullable(), Textarea::make('body')->nullable(), ]), Tab::make('Organisation', [ Panel::make('Relations', [ BelongsTo::make('category')->relatedResource('categories')->nullable(), BelongsTo::make('user', 'Author')->relatedResource('users')->nullable(), ])->collapsible(), Tag::make('tags', 'Tags')->relatedResource('tags')->withPreview(), MultiSelect::make('labels', 'Labels') ->options(['featured' => 'Featured', 'trending' => 'Trending', 'exclusive' => 'Exclusive']) ->nullable(), ]), Tab::make('Advanced', [ Panel::make('SEO & References', [ Text::make('source_url', 'Source URL')->nullable(), ])->collapsedByDefault(), ]), ]), ];}Tab configuration
Tab is a thin container — it accepts a title and a list of fields/panels. There are no fluent extras (no collapsible, no description, no icon). Use a Panel inside the tab if you need those features.
Where to use TabGroup
TabGroup may appear in:
fieldsForCreate()fieldsForUpdate()fieldsForDetail()
It does not appear in fields() (index) — fields are always flattened for the listing.
Possible Combinations
| Context | Panel | TabGroup | Tab inside TabGroup | Panel inside Tab | Section |
|---|---|---|---|---|---|
fields() (index) | ✗ | ✗ | ✗ | ✗ | ✗ |
fieldsForCreate | ✅ | ✅ | ✅ | ✅ | ✅ |
fieldsForUpdate | ✅ | ✅ | ✅ | ✅ | ✅ |
fieldsForDetail | ✅ | ✅ | ✅ | ✅ | ✅ |
Section cannot be nested inside a Tab (Tab accepts FieldContract|Panel only). See Grid Layout for the Section API.
Relationship panels inside layout containers
Standalone relationship fields — HasMany, HasOne, BelongsToMany, MorphMany, MorphOne, MorphToMany (and the OfMany / Through variants) — can be nested inside a Section, Panel, or TabGroup on the detail view. Their own relation endpoints (list, attach, detach, pivot) resolve correctly regardless of nesting depth.
public function fieldsForDetail(Request $request): array{ return [ Section::make('Team', [ BelongsToMany::make('Members', 'members')->relatedResource('users'), HasMany::make('Invitations', 'invitations')->relatedResource('invitations'), ]), ];}Before v1.29.2 the relation controllers resolved the field from
fieldsForDetail()without flattening layout containers, so a relation wrapped in aSection/Panel/TabGroupreturned404on every relation endpoint. It now flattens first, so nesting is fully supported.
Playground Showcase
The Layout Showcase resource at playground/app/Martis/Resources/LayoutShowcaseResource.php
demonstrates every combination described in this document:
- Create — demonstrates the 5 Panel variants (basic, collapsible, collapsedByDefault, limit, fields outside panels)
- Edit — demonstrates a TabGroup with 4 tabs (General, Content, Organisation with a nested Panel, Advanced)
- Detail — uses the same structure as Edit
To reach the showcase, navigate to /showcase/layout-showcase/create and /showcase/layout-showcase/{id}/edit on the playground.
JSON Serialization
The backend serialises Panels and TabGroups as part of the resource's field schema.
The format is stable and can be inspected via GET /api/{resource}/schema.
A field the user cannot see (canSee()) is left out of every serialised
layout, and a container (a Panel, a Section, a Tab or a TabGroup) left
without fields is left out with it: the schema's field lists, and since
v1.38.0 the fields of a Tool (GET /api/tools/{uriKey}/fields). The
built-in containers rebuild themselves with the fields they keep through
Martis\Contracts\FiltersFields (filterFields(\Closure $keep): ?static,
see Field::filterLayoutFields()); a custom container implementing
LayoutContract keeps its place when it implements FiltersFields too,
and otherwise gives way to the fields it keeps.
Panel
{ "type": "panel", "title": "Publication", "description": null, "collapsible": false, "collapsedByDefault": false, "limit": null, "fields": [ { "type": "select", "attribute": "status", ... }, { "type": "datetime", "attribute": "published_at", ... } ]}TabGroup
The fields key inside each tab holds a heterogeneous array — it may contain field objects and panel objects mixed together (panels are serialised inline, not promoted to a separate key).
{ "type": "tab_group", "tabs": [ { "type": "tab", "title": "General", "fields": [ { "type": "text", "attribute": "title", ... } ] }, { "type": "tab", "title": "Organisation", "fields": [ { "type": "text", "attribute": "note", ... }, { "type": "panel", "title": "Relations", "description": null, "collapsible": true, "collapsedByDefault": false, "limit": null, "fields": [ ... ] } ] } ]}Section
{ "type": "section", "title": "Timeline", "description": null, "columns": 12, "collapsible": false, "collapsedByDefault": false, "limit": null, "fields": [ { "type": "date", "attribute": "starts_at", "colSpan": 6, ... }, { "type": "date", "attribute": "ends_at", "colSpan": 6, ... } ]}