Skip to content
Abdulkader Safi edited this page Aug 30, 2026 · 1 revision

Menus

🚧 Coming soon. Built on a branch, behind an experimental flag, not in a release yet. Everything on this page describes what's there today so it's ready to use the moment it ships, not a promise about what it will eventually do.

Named navigation menus, editable from the panel, rendered on the public site: a primary nav, a footer, a sidebar, as many as you register. Locations are a fixed set a developer decides, the same idea as locales; what the client edits is a location's items, not the set of locations itself, the same split blocks already have between the type (code) and the content (the panel).

Turning it on

Off by default, since it's still being proven out:

// config/atelier.php
'experimental' => [
    'menus' => true,
],

Or per panel:

AtelierPlugin::make()->experimental(['menus' => true])

Turning it off (or leaving it off) pulls the Menus page and its route out of the panel entirely, not just its sidebar link, so there's a real way back to "this doesn't exist yet" if you turn it on and change your mind. See Experimental features.

Registering locations

// config/atelier.php, next to locales
'menus' => [
    'primary' => ['label' => 'Primary'],
    'footer' => ['label' => 'Footer'],
    'sidebar' => ['label' => 'Sidebar', 'depth' => 0],
],

depth is how many levels of children a location's items may nest. Default 1 (one level) when left out; 0 makes a location flat, no "Add a sub-item" control at all. The editor and the public partial are only built for one level regardless of a higher number here.

AtelierPlugin::make()->menuLocations([...]) in a panel provider adds to this rather than replacing it, for a location that only makes sense inside one specific panel, the same additive relationship ->experimental() has with its own config key.

Picking an existing model as an item

Any Eloquent model can become something a client picks instead of retyping, by implementing MenuSource:

use Safi\Atelier\MenuSource;

class Post extends Model implements MenuSource
{
    public static function menuSourceLabel(): string
    {
        return 'Post';
    }

    /** @return array<int, string> id to label, for the picker */
    public static function menuSourceOptions(): array
    {
        return static::query()->orderBy('title')->pluck('title', 'id')->all();
    }

    public static function menuSourceFind(int|string $id): ?static
    {
        return static::query()->find($id);
    }

    public function getMenuLabel(): string
    {
        return $this->title;
    }

    public function getMenuUrl(): string
    {
        return route('posts.show', $this);
    }
}
AtelierPlugin::make()->menuSources([Post::class])

Safi\Atelier\Models\Page already implements this, registered the same way: ->menuSources([\Safi\Atelier\Models\Page::class]). Picking one copies the label and URL into the item once, at the moment it's picked, rather than keeping a live reference: a menu is edited far more often than the things it points at are renamed, and a live foreign key would mean every public render resolves it, with a 500 waiting for the day someone deletes the row. Deleting the source model afterward changes nothing about the menu item.

Only the default locale gets prefilled. A source hands over one label and one URL, the same way typing a label only fills the tab you're on; the other locale is yours to translate.

Editing

A compact list, one row per item: a drag handle, the label, "Edit" and "Delete". Drag to reorder, including dragging a top-level item into another item's children to nest it, or out again to promote it back. Editing opens a modal, EN/AR tabs for the label and the URL, "Opens in" for the target. Hide an item without deleting it, the same eye toggle the page editor already has for blocks; hiding a parent hides its children too, on the public site, without hiding them in the editor, where they stay visible and dimmed.

Changes save as you make them. No explicit save button anywhere on this page.

Rendering it

Two ways, both starting from the same place:

use Safi\Atelier\Models\Menu;

Menu::treeFor('primary');          // the raw item tree, [] if empty or unregistered
Menu::label($item, 'ar');          // one item's label for a locale, falls back to default
Menu::url($item, 'ar');            // one item's URL for a locale, no fallback

The include the package ships, for the fastest path to something on the page:

@include('atelier::partials.menu', ['location' => 'primary', 'locale' => $locale])

Bare markup, no styling of its own: a <ul>, a <li> per item, one level of nested <ul> for children, aria-current="page" on an exact match, a font-semibold class on an exact match or an ancestor match. Override it at resources/views/vendor/atelier/partials/menu.blade.php in your own app if that's enough to work from.

For your own markup, skip the partial and call Menu::treeFor() directly from a normal view in your app:

@php
    $items = \Safi\Atelier\Models\Menu::treeFor('primary');
@endphp

<ul>
    @foreach ($items as $item)
        <li>
            <a href="{{ \Safi\Atelier\Models\Menu::url($item, $locale) ?: '#' }}">
                {{ \Safi\Atelier\Models\Menu::label($item, $locale) }}
            </a>

            @if (! empty($item['children']))
                {{-- one level, recurse or hand-roll it, your call --}}
            @endif
        </li>
    @endforeach
</ul>

This is what example/resources/views/partials/nav.blade.php does, and it's the more common path: writing a view in your own app, not overriding the package's, since that's the one most developers actually reach for.

Menu::url() deliberately has no locale fallback, unlike Menu::label(): English is /home, Arabic is /ar/home, and a URL borrowed from the wrong locale is a broken link, not a readable-but-untranslated one. It returns null when this locale has none, so check for that before you build an <a> around it.

A location nobody registered renders nothing and writes nothing, on purpose: the same reasoning LayoutRegistry::view() already applies to a missing layout, a typo in a Blade file shouldn't 500 a page that merely names it.

The item shape

{
  "id": "m_8f3a",
  "label": { "en": "About", "ar": "من نحن" },
  "url": { "en": "/about", "ar": "/ar/about" },
  "target": "_self",
  "hidden": false,
  "children": []
}

One JSON tree per location, the same tree-in-a-column shape a page's block tree already uses, not a relational nested-set or adjacency-list table: a menu is tens of items, not thousands, so the query-efficiency case for a relational hierarchy doesn't apply here, and this keeps the package storing hierarchy one way instead of two.

Known limits

  • One level of nesting. A location's own depth config can say more, but the editor and the shipped partial only ever build one level. Sub-items of sub-items aren't a thing yet.
  • No client-facing "create a menu" flow. Locations are code, the same as block types and layouts. What's client-facing is filling one in.
  • The menu manager itself is behind the experimental flag. Menu::treeFor() and the rendering side are not, a location you've filled in renders regardless of the flag; the flag only gates the editor page.

Clone this wiki locally