rg -n "navigation" tests/Feature tests/Unit -g '*.php' && sed -n '1,260p' tests/Feature/Admin/ContentManagementTest.php && sed -n '1,220p' tests/Feature/Storefront/ThemeDataTest.php

rg -n navigation tests/Feature tests/Unit -g '*.php'

Question: navigation

ThemeDataTest.php

tests/Feature/Storefront/ThemeDataTest.php:24:test('theme page and navigation tables and seeded fixtures exist', function () {
tests/Feature/Storefront/ThemeDataTest.php:31:        ->and(Schema::hasColumns('navigation_menus', ['store_id', 'handle', 'title']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:32:        ->and(Schema::hasColumns('navigation_items', ['menu_id', 'type', 'label', 'url', 'resource_id', 'position']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:41:test('theme page and navigation models are scoped to the resolved store', function () {
tests/Feature/Storefront/ThemeDataTest.php:59:test('theme and navigation child models are scoped through their parent store', function () {
tests/Feature/Storefront/ThemeDataTest.php:92:test('navigation service resolves resource URLs for seeded menus', function () {
tests/Feature/Admin/ContentManagementTest.php:55:test('content routes render store scoped pages navigation and themes', function (): void {
tests/Feature/Admin/ContentManagementTest.php:81:        ->get('/admin/navigation')
tests/Feature/Admin/ContentManagementTest.php:143:test('navigation menu items can be edited and persisted in order', function (): void {
<?php

use App\Enums\NavigationItemType;
use App\Enums\PageStatus;
use App\Enums\StoreUserRole;
use App\Enums\ThemeStatus;
use App\Livewire\Admin\Navigation\Index as AdminNavigationIndex;
use App\Livewire\Admin\Pages\Form as AdminPageForm;
use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
use App\Livewire\Admin\Themes\Editor as AdminThemeEditor;
use App\Livewire\Admin\Themes\Index as AdminThemesIndex;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminContentStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

function adminContentUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

function adminContentUserWithRole(Store $store, StoreUserRole $role): User
{
    $user = User::factory()->create();
    $user->stores()->attach($store->getKey(), [
        'role' => $role->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('content routes render store scoped pages navigation and themes', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    Page::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Page',
    ]);
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    $this->get('/admin/pages')->assertRedirect('/admin/login');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages')
        ->assertSuccessful()
        ->assertSee('About')
        ->assertDontSee('Other Store Page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages/create')
        ->assertSuccessful()
        ->assertSee('Create page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/navigation')
        ->assertSuccessful()
        ->assertSee('Main Menu');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes')
        ->assertSuccessful()
        ->assertSee($theme->name);

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes/'.$theme->getKey().'/editor')
        ->assertSuccessful()
        ->assertSee('Sections');
});

test('admin pages can be created searched edited and deleted', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();

    Livewire::actingAs($user)
        ->test(AdminPageForm::class)
        ->set('title', 'Sizing Guide')
        ->set('handle', 'sizing-guide')
        ->set('bodyHtml', '
Measure twice.
')
        ->set('status', PageStatus::Published->value)
        ->call('save')
        ->assertHasNoErrors();

    $page = Page::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'sizing-guide')
        ->firstOrFail();

    expect($page->status)->toBe(PageStatus::Published)
        ->and($page->published_at)->not->toBeNull();

    Livewire::actingAs($user)
        ->test(AdminPagesIndex::class)
        ->set('search', 'sizing')
        ->assertSee('Sizing Guide')
        ->assertDontSee('About');

…6844 chars truncated…nd(Theme::withoutGlobalScopes()->count())->toBe(2)
        ->and(ThemeFile::withoutGlobalScopes()->count())->toBe(6)
        ->and(ThemeSettings::withoutGlobalScopes()->count())->toBe(2)
        ->and(Page::withoutGlobalScopes()->count())->toBe(6)
        ->and(NavigationMenu::withoutGlobalScopes()->count())->toBe(4)
        ->and(NavigationItem::withoutGlobalScopes()->count())->toBeGreaterThanOrEqual(10);
});

test('theme page and navigation models are scoped to the resolved store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();

    Theme::factory()->published()->create(['store_id' => $firstStore->getKey(), 'name' => 'First Theme']);
    Theme::factory()->published()->create(['store_id' => $secondStore->getKey(), 'name' => 'Second Theme']);
    Page::factory()->published()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-page']);
    Page::factory()->published()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-page']);
    NavigationMenu::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-menu']);
    NavigationMenu::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-menu']);

    app()->instance('current_store', $firstStore);

    expect(Theme::query()->pluck('name')->all())->toBe(['First Theme'])
        ->and(Page::query()->pluck('handle')->all())->toBe(['first-page'])
        ->and(NavigationMenu::query()->pluck('handle')->all())->toBe(['first-menu']);
});

test('theme and navigation child models are scoped through their parent store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();
    $firstTheme = Theme::factory()->published()->create(['store_id' => $firstStore->getKey()]);
    $secondTheme = Theme::factory()->published()->create(['store_id' => $secondStore->getKey()]);
    $firstMenu = NavigationMenu::factory()->create(['store_id' => $firstStore->getKey()]);
    $secondMenu = NavigationMenu::factory()->create(['store_id' => $secondStore->getKey()]);

    ThemeFile::factory()->create(['theme_id' => $firstTheme->getKey(), 'path' => 'first.blade.php']);
    ThemeFile::factory()->create(['theme_id' => $secondTheme->getKey(), 'path' => 'second.blade.php']);
    ThemeSettings::factory()->create(['theme_id' => $firstTheme->getKey()]);
    ThemeSettings::factory()->create(['theme_id' => $secondTheme->getKey()]);
    NavigationItem::factory()->create(['menu_id' => $firstMenu->getKey(), 'label' => 'First']);
    NavigationItem::factory()->create(['menu_id' => $secondMenu->getKey(), 'label' => 'Second']);

    app()->instance('current_store', $firstStore);

    expect(ThemeFile::query()->pluck('path')->all())->toBe(['first.blade.php'])
        ->and(ThemeSettings::query()->count())->toBe(1)
        ->and(NavigationItem::query()->pluck('label')->all())->toBe(['First']);
});

test('theme settings service loads cached published settings with defaults', function () {
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $settings = app(ThemeSettingsService::class)->forStore($store);

    expect($settings['announcement']['text'])->toBe('Free shipping on orders over 75.00 EUR')
        ->and($settings['home']['hero']['heading'])->toBe('Acme Fashion')
        ->and(Cache::has("theme_settings:{$store->getKey()}"))->toBeTrue();
});

test('navigation service resolves resource URLs for seeded menus', function () {
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $menu = NavigationMenu::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'main-menu')
        ->firstOrFail();

    $items = app(NavigationService::class)->buildTree($menu);

    expect(collect($items)->pluck('label')->all())->toContain('New Arrivals', 'T-Shirts', 'About')
        ->and(collect($items)->firstWhere('label', 'New Arrivals')['url'])->toBe('/collections/new-arrivals')
        ->and(collect($items)->firstWhere('label', 'About')['url'])->toBe('/pages/about');
});

test('storefront pages render published database content only', function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

    Page::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'title' => 'Private Draft',
        'handle' => 'private-draft',
        'body_html' => '
This should not render.
',
        'status' => PageStatus::Draft,
        'published_at' => null,
    ]);

    $this->withHeader('Host', 'shop.test')
        ->get('/pages/about')
        ->assertSuccessful()
        ->assertSee('About')
        ->assertSee('Acme Fashion is the demo storefront');

    $this->withHeader('Host', 'shop.test')
        ->get('/pages/private-draft')
        ->assertNotFound();
});


Output:
Chunk ID: 3f0e39
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4211
Output:
tests/Feature/Storefront/ThemeDataTest.php:24:test('theme page and navigation tables and seeded fixtures exist', function () {
tests/Feature/Storefront/ThemeDataTest.php:31:        ->and(Schema::hasColumns('navigation_menus', ['store_id', 'handle', 'title']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:32:        ->and(Schema::hasColumns('navigation_items', ['menu_id', 'type', 'label', 'url', 'resource_id', 'position']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:41:test('theme page and navigation models are scoped to the resolved store', function () {
tests/Feature/Storefront/ThemeDataTest.php:59:test('theme and navigation child models are scoped through their parent store', function () {
tests/Feature/Storefront/ThemeDataTest.php:92:test('navigation service resolves resource URLs for seeded menus', function () {
tests/Feature/Admin/ContentManagementTest.php:55:test('content routes render store scoped pages navigation and themes', function (): void {
tests/Feature/Admin/ContentManagementTest.php:81:        ->get('/admin/navigation')
tests/Feature/Admin/ContentManagementTest.php:143:test('navigation menu items can be edited and persisted in order', function (): void {
<?php

use App\Enums\NavigationItemType;
use App\Enums\PageStatus;
use App\Enums\StoreUserRole;
use App\Enums\ThemeStatus;
use App\Livewire\Admin\Navigation\Index as AdminNavigationIndex;
use App\Livewire\Admin\Pages\Form as AdminPageForm;
use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
use App\Livewire\Admin\Themes\Editor as AdminThemeEditor;
use App\Livewire\Admin\Themes\Index as AdminThemesIndex;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSettings;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminContentStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

function adminContentUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

function adminContentUserWithRole(Store $store, StoreUserRole $role): User
{
    $user = User::factory()->create();
    $user->stores()->attach($store->getKey(), [
        'role' => $role->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('content routes render store scoped pages navigation and themes', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    Page::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Page',
    ]);
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    $this->get('/admin/pages')->assertRedirect('/admin/login');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages')
        ->assertSuccessful()
        ->assertSee('About')
        ->assertDontSee('Other Store Page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/pages/create')
        ->assertSuccessful()
        ->assertSee('Create page');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/navigation')
        ->assertSuccessful()
        ->assertSee('Main Menu');

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes')
        ->assertSuccessful()
        ->assertSee($theme->name);

    $this->actingAs($user)
        ->withSession(['current_store_id' => $store->getKey()])
        ->get('/admin/themes/'.$theme->getKey().'/editor')
        ->assertSuccessful()
        ->assertSee('Sections');
});

test('admin pages can be created searched edited and deleted', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();

    Livewire::actingAs($user)
        ->test(AdminPageForm::class)
        ->set('title', 'Sizing Guide')
        ->set('handle', 'sizing-guide')
        ->set('bodyHtml', '
Measure twice.
')
        ->set('status', PageStatus::Published->value)
        ->call('save')
        ->assertHasNoErrors();

    $page = Page::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'sizing-guide')
        ->firstOrFail();

    expect($page->status)->toBe(PageStatus::Published)
        ->and($page->published_at)->not->toBeNull();

    Livewire::actingAs($user)
        ->test(AdminPagesIndex::class)
        ->set('search', 'sizing')
        ->assertSee('Sizing Guide')
        ->assertDontSee('About');

    Livewire::actingAs($user)
        ->test(AdminPageForm::class, ['page' => $page])
        ->set('title', 'Size Guide')
        ->set('status', PageStatus::Draft->value)
        ->call('save')
        ->assertHasNoErrors();

    expect($page->refresh()->title)->toBe('Size Guide')
        ->and($page->status)->toBe(PageStatus::Draft);

    Livewire::actingAs($user)
        ->test(AdminPageForm::class, ['page' => $page])
        ->call('deletePage')
        ->assertRedirect(route('admin.pages.index', absolute: false));

    expect(Page::withoutGlobalScopes()->whereKey($page->getKey())->exists())->toBeFalse();
});

test('navigation menu items can be edited and persisted in order', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $menu = NavigationMenu::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'main-menu')
        ->firstOrFail();
    $initialItemCount = $menu->items()->count();

    Livewire::actingAs($user)
        ->test(AdminNavigationIndex::class)
        ->call('selectMenu', $menu->getKey())
        ->set('itemLabel', 'Lookbook')
        ->set('itemType', NavigationItemType::Link->value)
        ->set('itemUrl', '/lookbook')
        ->call('saveItem')
        ->call('moveItemUp', $initialItemCount)
        ->call('saveMenu')
        ->assertHasNoErrors();

    $items = NavigationItem::withoutGlobalScopes()
        ->where('menu_id', $menu->getKey())
        ->orderBy('position')
        ->get();

    expect($items->pluck('position')->all())->toBe(range(0, $items->count() - 1))
        ->and($items->pluck('label'))->toContain('Lookbook');
});

test('themes can be duplicated edited and published', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $published = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    Livewire::actingAs($user)
        ->test(AdminThemesIndex::class)
        ->call('duplicateTheme', $published->getKey())
        ->assertHasNoErrors();

    $copy = Theme::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('name', $published->name.' Copy')
        ->firstOrFail();
    $publishedFile = $published->files()->withoutGlobalScopes()->firstOrFail();
    $copyFile = $copy->files()->withoutGlobalScopes()->where('path', $publishedFile->path)->firstOrFail();

    expect($copyFile->storage_key)->not->toBe($publishedFile->storage_key)
        ->and(Storage::disk('local')->get($copyFile->storage_key))->toBe(Storage::disk('local')->get($publishedFile->storage_key));

    Livewire::actingAs($user)
        ->test(AdminThemeEditor::class, ['theme' => $copy])
        ->set('settings.home.hero.heading', 'New Hero')
        ->call('save')
        ->call('publish')
        ->assertHasNoErrors();

    expect(ThemeSettings::withoutGlobalScopes()->where('theme_id', $copy->getKey())->first()?->settings_json['home']['hero']['heading'])->toBe('New Hero')
        ->and($copy->refresh()->status)->toBe(ThemeStatus::Published)
        ->and($published->refresh()->status)->toBe(ThemeStatus::Draft)
        ->and(Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1);
});

test('theme editor saves file contents and metadata', function (): void {
    $store = adminContentStore();
    $user = adminContentUser();
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
    $file = $theme->files()->withoutGlobalScopes()->where('path', 'sections/hero.blade.php')->firstOrFail();

    Storage::disk('local')->put($file->storage_key, 'Original theme file');

    Livewire::actingAs($user)
        ->test(AdminThemeEditor::class, ['theme' => $theme])
        ->call('selectFile', $file->getKey())
        ->assertSet('selectedFileId', $file->getKey())
        ->assertSet('fileContents', 'Original theme file')
        ->set('fileContents', '
Edited hero file
')
        ->call('saveFile')
        ->assertHasNoErrors()
        ->assertSee('Theme file saved');

    expect(Storage::disk('local')->get($file->storage_key))->toBe('
Edited hero file
')
        ->and($file->refresh()->sha256)->toBe(hash('sha256', '
Edited hero file
'))
        ->and($file->byte_size)->toBe(strlen('
Edited hero file
'));
});

test('content management honors store roles and store scoping', function (): void {
    $store = adminContentStore();
    $support = adminContentUserWithRole($store, StoreUserRole::Support);
    $staff = adminContentUserWithRole($store, StoreUserRole::Staff);
    $otherPage = Page::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
    ]);
    $theme = Theme::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    Livewire::actingAs($support)
        ->test(AdminPageForm::class)
        ->assertStatus(403);

    Livewire::actingAs($staff)
        ->test(AdminThemesIndex::class)
        ->assertStatus(403);

    Livewire::actingAs($staff)
        ->test(AdminNavigationIndex::class)
        ->assertStatus(403);

    Livewire::actingAs(adminContentUser())
        ->test(AdminPageForm::class, ['page' => $otherPage])
        ->assertStatus(404);

    Livewire::actingAs(adminContentUser())
        ->test(AdminThemeEditor::class, ['theme' => $theme])
        ->assertSuccessful();
});
<?php

use App\Enums\PageStatus;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Services\NavigationService;
use App\Services\ThemeSettingsService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;

uses(RefreshDatabase::class);

beforeEach(function () {
    Cache::flush();
});

test('theme page and navigation tables and seeded fixtures exist', function () {
    $this->seed(DatabaseSeeder::class);

    expect(Schema::hasColumns('themes', ['store_id', 'name', 'version', 'status', 'published_at']))->toBeTrue()
        ->and(Schema::hasColumns('theme_files', ['theme_id', 'path', 'storage_key', 'sha256', 'byte_size']))->toBeTrue()
        ->and(Schema::hasColumns('theme_settings', ['theme_id', 'settings_json', 'updated_at']))->toBeTrue()
        ->and(Schema::hasColumns('pages', ['store_id', 'title', 'handle', 'body_html', 'status', 'published_at']))->toBeTrue()
        ->and(Schema::hasColumns('navigation_menus', ['store_id', 'handle', 'title']))->toBeTrue()
        ->and(Schema::hasColumns('navigation_items', ['menu_id', 'type', 'label', 'url', 'resource_id', 'position']))->toBeTrue()
        ->and(Theme::withoutGlobalScopes()->count())->toBe(2)
        ->and(ThemeFile::withoutGlobalScopes()->count())->toBe(6)
        ->and(ThemeSettings::withoutGlobalScopes()->count())->toBe(2)
        ->and(Page::withoutGlobalScopes()->count())->toBe(6)
        ->and(NavigationMenu::withoutGlobalScopes()->count())->toBe(4)
        ->and(NavigationItem::withoutGlobalScopes()->count())->toBeGreaterThanOrEqual(10);
});

test('theme page and navigation models are scoped to the resolved store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();

    Theme::factory()->published()->create(['store_id' => $firstStore->getKey(), 'name' => 'First Theme']);
    Theme::factory()->published()->create(['store_id' => $secondStore->getKey(), 'name' => 'Second Theme']);
    Page::factory()->published()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-page']);
    Page::factory()->published()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-page']);
    NavigationMenu::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-menu']);
    NavigationMenu::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-menu']);

    app()->instance('current_store', $firstStore);

    expect(Theme::query()->pluck('name')->all())->toBe(['First Theme'])
        ->and(Page::query()->pluck('handle')->all())->toBe(['first-page'])
        ->and(NavigationMenu::query()->pluck('handle')->all())->toBe(['first-menu']);
});

test('theme and navigation child models are scoped through their parent store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();
    $firstTheme = Theme::factory()->published()->create(['store_id' => $firstStore->getKey()]);
    $secondTheme = Theme::factory()->published()->create(['store_id' => $secondStore->getKey()]);
    $firstMenu = NavigationMenu::factory()->create(['store_id' => $firstStore->getKey()]);
    $secondMenu = NavigationMenu::factory()->create(['store_id' => $secondStore->getKey()]);

    ThemeFile::factory()->create(['theme_id' => $firstTheme->getKey(), 'path' => 'first.blade.php']);
    ThemeFile::factory()->create(['theme_id' => $secondTheme->getKey(), 'path' => 'second.blade.php']);
    ThemeSettings::factory()->create(['theme_id' => $firstTheme->getKey()]);
    ThemeSettings::factory()->create(['theme_id' => $secondTheme->getKey()]);
    NavigationItem::factory()->create(['menu_id' => $firstMenu->getKey(), 'label' => 'First']);
    NavigationItem::factory()->create(['menu_id' => $secondMenu->getKey(), 'label' => 'Second']);

    app()->instance('current_store', $firstStore);

    expect(ThemeFile::query()->pluck('path')->all())->toBe(['first.blade.php'])
        ->and(ThemeSettings::query()->count())->toBe(1)
        ->and(NavigationItem::query()->pluck('label')->all())->toBe(['First']);
});

test('theme settings service loads cached published settings with defaults', function () {
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $settings = app(ThemeSettingsService::class)->forStore($store);

    expect($settings['announcement']['text'])->toBe('Free shipping on orders over 75.00 EUR')
        ->and($settings['home']['hero']['heading'])->toBe('Acme Fashion')
        ->and(Cache::has("theme_settings:{$store->getKey()}"))->toBeTrue();
});

test('navigation service resolves resource URLs for seeded menus', function () {
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $menu = NavigationMenu::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'main-menu')
        ->firstOrFail();

    $items = app(NavigationService::class)->buildTree($menu);

    expect(collect($items)->pluck('label')->all())->toContain('New Arrivals', 'T-Shirts', 'About')
        ->and(collect($items)->firstWhere('label', 'New Arrivals')['url'])->toBe('/collections/new-arrivals')
        ->and(collect($items)->firstWhere('label', 'About')['url'])->toBe('/pages/about');
});

test('storefront pages render published database content only', function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);

    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

    Page::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'title' => 'Private Draft',
        'handle' => 'private-draft',
        'body_html' => '
This should not render.
',
        'status' => PageStatus::Draft,
        'published_at' => null,
    ]);

    $this->withHeader('Host', 'shop.test')
        ->get('/pages/about')
        ->assertSuccessful()
        ->assertSee('About')
        ->assertSee('Acme Fashion is the demo storefront');

    $this->withHeader('Host', 'shop.test')
        ->get('/pages/private-draft')
        ->assertNotFound();
});


The current app has the right admin/navigation surface but the data model is flat. I’m adding a nullable `parent_id` to navigation items, then wiring one-level children through the service, admin editor, seeded fixtures, and storefront dropdown rendering.


php artisan make:migration add_parent_id_to_navigation_items_table --table=navigation_items --no-interaction


Output:
Chunk ID: 30db26
Wall time: 0.1858 seconds
Process exited with code 0
Original token count: 33
Output:

   INFO  Migration [database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php] created successfully.


php artisan make:migration add_parent_id_to_navigation_items_table --table=navigation_items --no-interaction

INFO  Migration [database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php] created successfully.


sed -n '1,160p' database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php


sed -n '1,160p' database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('navigation_items', function (Blueprint $table) {
            //
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('navigation_items', function (Blueprint $table) {
            //
        });
    }
};


Output:
Chunk ID: 47dd5b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 138
Output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('navigation_items', function (Blueprint $table) {
            //
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('navigation_items', function (Blueprint $table) {
            //
        });
    }
};


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php
@@
     public function up(): void
     {
         Schema::table('navigation_items', function (Blueprint $table) {
-            //
+            $table->foreignId('parent_id')
+                ->nullable()
+                ->after('menu_id')
+                ->constrained('navigation_items')
+                ->cascadeOnDelete();
+
+            $table->index(['menu_id', 'parent_id', 'position'], 'idx_navigation_items_menu_parent_position');
         });
     }
@@
     public function down(): void
     {
         Schema::table('navigation_items', function (Blueprint $table) {
-            //
+            $table->dropIndex('idx_navigation_items_menu_parent_position');
+            $table->dropConstrainedForeignId('parent_id');
         });
     }
 };
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php

@@ -14,3 +14,9 @@
         Schema::table('navigation_items', function (Blueprint $table) {
-            //
+            $table->foreignId('parent_id')
+                ->nullable()
+                ->after('menu_id')
+                ->constrained('navigation_items')
+                ->cascadeOnDelete();
+
+            $table->index(['menu_id', 'parent_id', 'position'], 'idx_navigation_items_menu_parent_position');
         });
@@ -24,3 +30,4 @@
         Schema::table('navigation_items', function (Blueprint $table) {
-            //
+            $table->dropIndex('idx_navigation_items_menu_parent_position');
+            $table->dropConstrainedForeignId('parent_id');
         });


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/migrations/2026_05_04_055004_add_parent_id_to_navigation_items_table.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Models/NavigationItem.php
@@
 use App\Enums\NavigationItemType;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
@@
     protected $fillable = [
         'menu_id',
+        'parent_id',
         'type',
         'label',
         'url',
@@
     public function menu(): BelongsTo
     {
         return $this->belongsTo(NavigationMenu::class, 'menu_id');
     }

+    /**
+     * @return BelongsTo<NavigationItem, $this>
+     */
+    public function parent(): BelongsTo
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    /**
+     * @return HasMany<NavigationItem, $this>
+     */
+    public function children(): HasMany
+    {
+        return $this->hasMany(self::class, 'parent_id')->orderBy('position');
+    }
+
     /**
      * @return array<string, string>
      */
     protected function casts(): array
     {
         return [
             'type' => NavigationItemType::class,
+            'parent_id' => 'integer',
             'resource_id' => 'integer',
             'position' => 'integer',
         ];
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Models/NavigationItem.php

@@ -9,2 +9,3 @@
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;

@@ -22,2 +23,3 @@
         'menu_id',
+        'parent_id',
         'type',
@@ -67,2 +69,18 @@
     /**
+     * @return BelongsTo<NavigationItem, $this>
+     */
+    public function parent(): BelongsTo
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    /**
+     * @return HasMany<NavigationItem, $this>
+     */
+    public function children(): HasMany
+    {
+        return $this->hasMany(self::class, 'parent_id')->orderBy('position');
+    }
+
+    /**
      * @return array<string, string>
@@ -73,2 +91,3 @@
             'type' => NavigationItemType::class,
+            'parent_id' => 'integer',
             'resource_id' => 'integer',


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Models/NavigationItem.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/NavigationItemFactory.php
@@
         return [
             'menu_id' => NavigationMenu::factory(),
+            'parent_id' => null,
             'type' => NavigationItemType::Link,
             'label' => fake()->words(2, true),
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/factories/NavigationItemFactory.php

@@ -22,2 +22,3 @@
             'menu_id' => NavigationMenu::factory(),
+            'parent_id' => null,
             'type' => NavigationItemType::Link,


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/factories/NavigationItemFactory.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/NavigationService.php
@@
     public function buildTree(NavigationMenu $menu): array
     {
         return Cache::remember($this->cacheKey($menu), now()->addMinutes(5), function () use ($menu): array {
-            return $menu->items()
+            $items = $menu->items()
                 ->with('menu')
+                ->orderBy('parent_id')
+                ->orderBy('position')
                 ->get()
-                ->map(fn (NavigationItem $item): array => [
+
+            $itemsByParent = $items->groupBy(fn (NavigationItem $item): string => $item->parent_id === null ? 'root' : (string) $item->parent_id);
+
+            $build = function (string $parentKey) use (&$build, $itemsByParent): array {
+                return $itemsByParent->get($parentKey, collect())
+                    ->sortBy('position')
+                    ->map(fn (NavigationItem $item): array => [
+                        'label' => $item->label,
+                        'url' => $this->resolveUrl($item),
+                        'type' => $item->type->value,
+                        'external' => $this->isExternal((string) ($item->url ?? '')),
+                        'children' => $build((string) $item->getKey()),
+                    ])
+                    ->values()
+                    ->all();
+            };
+
+            return $build('root');
+        });
+    }
+
+    /**
+     * @return array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}
+     */
+    private function node(NavigationItem $item, array $children): array
+    {
+        return [
                     'label' => $item->label,
                     'url' => $this->resolveUrl($item),
                     'type' => $item->type->value,
                     'external' => $this->isExternal((string) ($item->url ?? '')),
-                    'children' => [],
-                ])
-                ->all();
-        });
+            'children' => $children,
+        ];
     }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/NavigationService.php

@@ -39,6 +39,34 @@
         return Cache::remember($this->cacheKey($menu), now()->addMinutes(5), function () use ($menu): array {
-            return $menu->items()
+            $items = $menu->items()
                 ->with('menu')
+                ->orderBy('parent_id')
+                ->orderBy('position')
                 ->get()
-                ->map(fn (NavigationItem $item): array => [
+
+            $itemsByParent = $items->groupBy(fn (NavigationItem $item): string => $item->parent_id === null ? 'root' : (string) $item->parent_id);
+
+            $build = function (string $parentKey) use (&$build, $itemsByParent): array {
+                return $itemsByParent->get($parentKey, collect())
+                    ->sortBy('position')
+                    ->map(fn (NavigationItem $item): array => [
+                        'label' => $item->label,
+                        'url' => $this->resolveUrl($item),
+                        'type' => $item->type->value,
+                        'external' => $this->isExternal((string) ($item->url ?? '')),
+                        'children' => $build((string) $item->getKey()),
+                    ])
+                    ->values()
+                    ->all();
+            };
+
+            return $build('root');
+        });
+    }
+
+    /**
+     * @return array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}
+     */
+    private function node(NavigationItem $item, array $children): array
+    {
+        return [
                     'label' => $item->label,
@@ -47,6 +75,4 @@
                     'external' => $this->isExternal((string) ($item->url ?? '')),
-                    'children' => [],
-                ])
-                ->all();
-        });
+            'children' => $children,
+        ];
     }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/NavigationService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


sed -n '1,140p' app/Services/NavigationService.php


sed -n '1,140p' app/Services/NavigationService.php

<?php

namespace App\Services;

use App\Enums\NavigationItemType;
use App\Models\Collection as ProductCollection;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Product;
use App\Models\Store;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class NavigationService
{
    /**
     * @return array<int, array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}>
     */
    public function forHandle(Store $store, string $handle): array
    {
        $menu = NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('handle', $handle)
            ->first();

        if ($menu === null) {
            return [];
        }

        return $this->buildTree($menu);
    }

    /**
     * @return array<int, array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}>
     */
    public function buildTree(NavigationMenu $menu): array
    {
        return Cache::remember($this->cacheKey($menu), now()->addMinutes(5), function () use ($menu): array {
            $items = $menu->items()
                ->with('menu')
                ->orderBy('parent_id')
                ->orderBy('position')
                ->get()

            $itemsByParent = $items->groupBy(fn (NavigationItem $item): string => $item->parent_id === null ? 'root' : (string) $item->parent_id);

            $build = function (string $parentKey) use (&$build, $itemsByParent): array {
                return $itemsByParent->get($parentKey, collect())
                    ->sortBy('position')
                    ->map(fn (NavigationItem $item): array => [
                        'label' => $item->label,
                        'url' => $this->resolveUrl($item),
                        'type' => $item->type->value,
                        'external' => $this->isExternal((string) ($item->url ?? '')),
                        'children' => $build((string) $item->getKey()),
                    ])
                    ->values()
                    ->all();
            };

            return $build('root');
        });
    }

    /**
     * @return array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}
     */
    private function node(NavigationItem $item, array $children): array
    {
        return [
                    'label' => $item->label,
                    'url' => $this->resolveUrl($item),
                    'type' => $item->type->value,
                    'external' => $this->isExternal((string) ($item->url ?? '')),
            'children' => $children,
        ];
    }

    public function resolveUrl(NavigationItem $item): string
    {
        return match ($item->type) {
            NavigationItemType::Link => $item->url ?: '#',
            NavigationItemType::Page => $this->pageUrl($item),
            NavigationItemType::Collection => $this->collectionUrl($item),
            NavigationItemType::Product => $this->productUrl($item),
        };
    }

    public function forget(NavigationMenu $menu): void
    {
        Cache::forget($this->cacheKey($menu));
    }

    private function pageUrl(NavigationItem $item): string
    {
        $page = Page::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $page ? route('pages.show', $page->handle, false) : '#';
    }

    private function collectionUrl(NavigationItem $item): string
    {
        $collection = ProductCollection::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $collection ? route('collections.show', $collection->handle, false) : '#';
    }

    private function productUrl(NavigationItem $item): string
    {
        $product = Product::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $product ? route('products.show', $product->handle, false) : '#';
    }

    private function storeIdFor(NavigationItem $item): ?int
    {
        if ($item->relationLoaded('menu') && $item->menu !== null) {
            return (int) $item->menu->store_id;
        }

        return NavigationMenu::withoutGlobalScopes()
            ->whereKey($item->menu_id)
            ->value('store_id');
    }

    private function isExternal(string $url): bool
    {
        return Str::startsWith($url, ['http://', 'https://', 'mailto:', 'tel:']);
    }

    private function cacheKey(NavigationMenu $menu): string
    {
        return "navigation:{$menu->store_id}:{$menu->handle}";


Output:
Chunk ID: 2289c6
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1179
Output:
<?php

namespace App\Services;

use App\Enums\NavigationItemType;
use App\Models\Collection as ProductCollection;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Product;
use App\Models\Store;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class NavigationService
{
    /**
     * @return array<int, array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}>
     */
    public function forHandle(Store $store, string $handle): array
    {
        $menu = NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('handle', $handle)
            ->first();

        if ($menu === null) {
            return [];
        }

        return $this->buildTree($menu);
    }

    /**
     * @return array<int, array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}>
     */
    public function buildTree(NavigationMenu $menu): array
    {
        return Cache::remember($this->cacheKey($menu), now()->addMinutes(5), function () use ($menu): array {
            $items = $menu->items()
                ->with('menu')
                ->orderBy('parent_id')
                ->orderBy('position')
                ->get()

            $itemsByParent = $items->groupBy(fn (NavigationItem $item): string => $item->parent_id === null ? 'root' : (string) $item->parent_id);

            $build = function (string $parentKey) use (&$build, $itemsByParent): array {
                return $itemsByParent->get($parentKey, collect())
                    ->sortBy('position')
                    ->map(fn (NavigationItem $item): array => [
                        'label' => $item->label,
                        'url' => $this->resolveUrl($item),
                        'type' => $item->type->value,
                        'external' => $this->isExternal((string) ($item->url ?? '')),
                        'children' => $build((string) $item->getKey()),
                    ])
                    ->values()
                    ->all();
            };

            return $build('root');
        });
    }

    /**
     * @return array{label: string, url: string, type: string, external: bool, children: array<int, mixed>}
     */
    private function node(NavigationItem $item, array $children): array
    {
        return [
                    'label' => $item->label,
                    'url' => $this->resolveUrl($item),
                    'type' => $item->type->value,
                    'external' => $this->isExternal((string) ($item->url ?? '')),
            'children' => $children,
        ];
    }

    public function resolveUrl(NavigationItem $item): string
    {
        return match ($item->type) {
            NavigationItemType::Link => $item->url ?: '#',
            NavigationItemType::Page => $this->pageUrl($item),
            NavigationItemType::Collection => $this->collectionUrl($item),
            NavigationItemType::Product => $this->productUrl($item),
        };
    }

    public function forget(NavigationMenu $menu): void
    {
        Cache::forget($this->cacheKey($menu));
    }

    private function pageUrl(NavigationItem $item): string
    {
        $page = Page::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $page ? route('pages.show', $page->handle, false) : '#';
    }

    private function collectionUrl(NavigationItem $item): string
    {
        $collection = ProductCollection::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $collection ? route('collections.show', $collection->handle, false) : '#';
    }

    private function productUrl(NavigationItem $item): string
    {
        $product = Product::withoutGlobalScopes()
            ->where('store_id', $this->storeIdFor($item))
            ->find($item->resource_id);

        return $product ? route('products.show', $product->handle, false) : '#';
    }

    private function storeIdFor(NavigationItem $item): ?int
    {
        if ($item->relationLoaded('menu') && $item->menu !== null) {
            return (int) $item->menu->store_id;
        }

        return NavigationMenu::withoutGlobalScopes()
            ->whereKey($item->menu_id)
            ->value('store_id');
    }

    private function isExternal(string $url): bool
    {
        return Str::startsWith($url, ['http://', 'https://', 'mailto:', 'tel:']);
    }

    private function cacheKey(NavigationMenu $menu): string
    {
        return "navigation:{$menu->store_id}:{$menu->handle}";


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/NavigationService.php
@@
             $items = $menu->items()
                 ->with('menu')
                 ->orderBy('parent_id')
                 ->orderBy('position')
-                ->get()
+                ->get();
@@
                 return $itemsByParent->get($parentKey, collect())
                     ->sortBy('position')
-                    ->map(fn (NavigationItem $item): array => [
-                        'label' => $item->label,
-                        'url' => $this->resolveUrl($item),
-                        'type' => $item->type->value,
-                        'external' => $this->isExternal((string) ($item->url ?? '')),
-                        'children' => $build((string) $item->getKey()),
-                    ])
+                    ->map(fn (NavigationItem $item): array => $this->node($item, $build((string) $item->getKey())))
                     ->values()
                     ->all();
             };
@@
     private function node(NavigationItem $item, array $children): array
     {
         return [
-                    'label' => $item->label,
-                    'url' => $this->resolveUrl($item),
-                    'type' => $item->type->value,
-                    'external' => $this->isExternal((string) ($item->url ?? '')),
+            'label' => $item->label,
+            'url' => $this->resolveUrl($item),
+            'type' => $item->type->value,
+            'external' => $this->isExternal((string) ($item->url ?? '')),
             'children' => $children,
         ];
     }
*** End Patch


@@ -43,3 +43,3 @@
                 ->orderBy('position')
-                ->get()
+                ->get();

@@ -50,9 +50,3 @@
                     ->sortBy('position')
-                    ->map(fn (NavigationItem $item): array => [
-                        'label' => $item->label,
-                        'url' => $this->resolveUrl($item),
-                        'type' => $item->type->value,
-                        'external' => $this->isExternal((string) ($item->url ?? '')),
-                        'children' => $build((string) $item->getKey()),
-                    ])
+                    ->map(fn (NavigationItem $item): array => $this->node($item, $build((string) $item->getKey())))
                     ->values()
@@ -71,6 +65,6 @@
         return [
-                    'label' => $item->label,
-                    'url' => $this->resolveUrl($item),
-                    'type' => $item->type->value,
-                    'external' => $this->isExternal((string) ($item->url ?? '')),
+            'label' => $item->label,
+            'url' => $this->resolveUrl($item),
+            'type' => $item->type->value,
+            'external' => $this->isExternal((string) ($item->url ?? '')),
             'children' => $children,


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/NavigationService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Question: pants|jeans|sale|new-arrivals|t-shirts|accessories|featured

database/seeders/CollectionSeeder.php:                ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Discover the latest additions to our store.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Premium cotton tees for every occasion.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Pants & Jeans', 'handle' => 'pants-jeans', 'description_html' => '
Find the perfect fit from our denim and trouser range.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Great deals on selected items.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Featured', 'handle' => 'featured', 'description_html' => '
Featured electronics for tenant isolation tests.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Electronics accessories.
'],
database/seeders/ProductSeeder.php:            $this->product('Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'Acme Basics', 'T-Shirts', ['new', 'popular'], ['new-arrivals', 't-shirts'], '
A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.
', [['Size' => ['S', 'M', 'L', 'XL']], ['Color' => ['White', 'Black', 'Navy']]], ['price_amount' => 2499, 'weight_g' => 200, 'quantity_on_hand' => 15], 'ACME-CTSH'),
database/seeders/ProductSeeder.php:            $this->product('Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'Acme Denim', 'Pants', ['new', 'sale'], ['new-arrivals', 'pants-jeans', 'sale'], '
Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.
', [['Size' => ['28', '30', '32', '34', '36']], ['Color' => ['Blue', 'Black']]], ['price_amount' => 7999, 'compare_at_amount' => 9999, 'weight_g' => 800, 'quantity_on_hand' => 8], 'ACME-JNS'),
database/seeders/ProductSeeder.php:            $this->product('Organic Hoodie', 'organic-hoodie', 'Acme Basics', 'Hoodies', ['new', 'trending'], ['new-arrivals'], '
Made from 100% organic cotton. Warm, soft, and sustainably produced.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 5999, 'weight_g' => 500, 'quantity_on_hand' => 20], 'ACME-HOOD'),
database/seeders/ProductSeeder.php:            $this->product('Running Sneakers', 'running-sneakers', 'Acme Sport', 'Shoes', ['trending'], ['new-arrivals'], '
Lightweight running sneakers with responsive cushioning and breathable mesh upper.
', [['Size' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], ['Color' => ['White', 'Black']]], ['price_amount' => 11999, 'weight_g' => 600, 'quantity_on_hand' => 5], 'ACME-RUN'),
database/seeders/ProductSeeder.php:            $this->product('Graphic Print Tee', 'graphic-print-tee', 'Acme Basics', 'T-Shirts', ['new'], ['t-shirts'], '
Bold graphic print on soft cotton. Express yourself with this statement piece.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2999, 'weight_g' => 210, 'quantity_on_hand' => 18], 'ACME-GTEE'),
database/seeders/ProductSeeder.php:            $this->product('V-Neck Linen Tee', 'v-neck-linen-tee', 'Acme Basics', 'T-Shirts', ['popular'], ['t-shirts'], '
Lightweight linen blend v-neck. Perfect for warm summer days.
', [['Size' => ['S', 'M', 'L']], ['Color' => ['Beige', 'Olive', 'Sky Blue']]], ['price_amount' => 3499, 'weight_g' => 180, 'quantity_on_hand' => 12], 'ACME-VNECK'),
database/seeders/ProductSeeder.php:            $this->product('Striped Polo Shirt', 'striped-polo-shirt', 'Acme Basics', 'T-Shirts', ['sale'], ['t-shirts', 'sale'], '
Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2799, 'compare_at_amount' => 3999, 'weight_g' => 250, 'quantity_on_hand' => 10], 'ACME-POLO'),
database/seeders/ProductSeeder.php:            $this->product('Cargo Pants', 'cargo-pants', 'Acme Workwear', 'Pants', ['popular'], ['pants-jeans'], '
Utility cargo pants with multiple pockets. Durable cotton twill construction.
', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Khaki', 'Olive', 'Black']]], ['price_amount' => 5499, 'weight_g' => 700, 'quantity_on_hand' => 14], 'ACME-CARGO'),
database/seeders/ProductSeeder.php:            $this->product('Chino Shorts', 'chino-shorts', 'Acme Basics', 'Pants', ['new', 'trending'], ['pants-jeans', 'new-arrivals'], '
Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.
', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Navy', 'Sand']]], ['price_amount' => 3999, 'weight_g' => 350, 'quantity_on_hand' => 16], 'ACME-CHINO'),
database/seeders/ProductSeeder.php:            $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'Acme Denim', 'Pants', ['sale'], ['pants-jeans', 'sale'], '
Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.
', [['Size' => ['S', 'M', 'L']]], ['price_amount' => 4999, 'compare_at_amount' => 6999, 'weight_g' => 550, 'quantity_on_hand' => 7], 'ACME-WIDE'),
database/seeders/ProductSeeder.php:            $this->product('Bucket Hat', 'bucket-hat', 'Acme Accessories', 'Accessories', ['new', 'trending'], ['new-arrivals'], '
Lightweight bucket hat for sun protection. Packable design, washed cotton twill.
', [['Size' => ['S/M', 'L/XL']], ['Color' => ['Beige', 'Black', 'Olive']]], ['price_amount' => 2499, 'weight_g' => 80, 'quantity_on_hand' => 22], 'ACME-HAT'),
database/seeders/ProductSeeder.php:            $this->product('Cashmere Overcoat', 'cashmere-overcoat', 'Acme Premium', 'Jackets', ['limited', 'new'], ['new-arrivals'], '
Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.
', [['Size' => ['S', 'M', 'L']], ['Color' => ['Camel', 'Charcoal']]], ['price_amount' => 49999, 'weight_g' => 1200, 'quantity_on_hand' => 3], 'ACME-COAT'),
database/seeders/ProductSeeder.php:            $this->product('Pro Laptop 15', 'pro-laptop-15', 'TechCorp', 'Laptops', ['featured'], ['featured'], '
Performance laptop for tenant isolation tests.
', [['Storage' => ['256GB', '512GB', '1TB']]], ['price_amount' => 99999, 'weight_g' => 1800, 'quantity_on_hand' => 10], 'TECH-LAPTOP', variants: [
database/seeders/ProductSeeder.php:            $this->product('Wireless Headphones', 'wireless-headphones', 'AudioMax', 'Audio', ['featured'], ['featured'], '
Wireless headphones for tenant isolation tests.
', [['Color' => ['Black', 'Silver']]], ['price_amount' => 14999, 'weight_g' => 250, 'quantity_on_hand' => 25], 'TECH-HEADPHONES'),
database/seeders/ProductSeeder.php:            $this->product('USB-C Cable 2m', 'usb-c-cable-2m', 'CablePro', 'Cables', ['accessories'], ['accessories'], '
Two meter USB-C cable.
', [], ['price_amount' => 1299, 'weight_g' => 50, 'quantity_on_hand' => 200], 'TECH-CABLE'),
database/seeders/ProductSeeder.php:            $this->product('Mechanical Keyboard', 'mechanical-keyboard', 'KeyTech', 'Peripherals', ['featured'], ['featured'], '
Mechanical keyboard with selectable switch types.
', [['Switch Type' => ['Red', 'Blue', 'Brown']]], ['price_amount' => 12999, 'weight_g' => 1100, 'quantity_on_hand' => 15], 'TECH-KEYBOARD'),
database/seeders/ProductSeeder.php:            $this->product('Monitor Stand', 'monitor-stand', 'DeskGear', 'Accessories', ['accessories'], ['accessories'], '
Desktop monitor stand.
', [], ['price_amount' => 4999, 'weight_g' => 2500, 'quantity_on_hand' => 30], 'TECH-STAND'),


Output:
Chunk ID: 19a44c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1911
Output:
database/seeders/CollectionSeeder.php:                ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Discover the latest additions to our store.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Premium cotton tees for every occasion.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Pants & Jeans', 'handle' => 'pants-jeans', 'description_html' => '
Find the perfect fit from our denim and trouser range.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Great deals on selected items.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Featured', 'handle' => 'featured', 'description_html' => '
Featured electronics for tenant isolation tests.
'],
database/seeders/CollectionSeeder.php:                ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Electronics accessories.
'],
database/seeders/ProductSeeder.php:            $this->product('Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'Acme Basics', 'T-Shirts', ['new', 'popular'], ['new-arrivals', 't-shirts'], '
A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.
', [['Size' => ['S', 'M', 'L', 'XL']], ['Color' => ['White', 'Black', 'Navy']]], ['price_amount' => 2499, 'weight_g' => 200, 'quantity_on_hand' => 15], 'ACME-CTSH'),
database/seeders/ProductSeeder.php:            $this->product('Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'Acme Denim', 'Pants', ['new', 'sale'], ['new-arrivals', 'pants-jeans', 'sale'], '
Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.
', [['Size' => ['28', '30', '32', '34', '36']], ['Color' => ['Blue', 'Black']]], ['price_amount' => 7999, 'compare_at_amount' => 9999, 'weight_g' => 800, 'quantity_on_hand' => 8], 'ACME-JNS'),
database/seeders/ProductSeeder.php:            $this->product('Organic Hoodie', 'organic-hoodie', 'Acme Basics', 'Hoodies', ['new', 'trending'], ['new-arrivals'], '
Made from 100% organic cotton. Warm, soft, and sustainably produced.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 5999, 'weight_g' => 500, 'quantity_on_hand' => 20], 'ACME-HOOD'),
database/seeders/ProductSeeder.php:            $this->product('Running Sneakers', 'running-sneakers', 'Acme Sport', 'Shoes', ['trending'], ['new-arrivals'], '
Lightweight running sneakers with responsive cushioning and breathable mesh upper.
', [['Size' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], ['Color' => ['White', 'Black']]], ['price_amount' => 11999, 'weight_g' => 600, 'quantity_on_hand' => 5], 'ACME-RUN'),
database/seeders/ProductSeeder.php:            $this->product('Graphic Print Tee', 'graphic-print-tee', 'Acme Basics', 'T-Shirts', ['new'], ['t-shirts'], '
Bold graphic print on soft cotton. Express yourself with this statement piece.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2999, 'weight_g' => 210, 'quantity_on_hand' => 18], 'ACME-GTEE'),
database/seeders/ProductSeeder.php:            $this->product('V-Neck Linen Tee', 'v-neck-linen-tee', 'Acme Basics', 'T-Shirts', ['popular'], ['t-shirts'], '
Lightweight linen blend v-neck. Perfect for warm summer days.
', [['Size' => ['S', 'M', 'L']], ['Color' => ['Beige', 'Olive', 'Sky Blue']]], ['price_amount' => 3499, 'weight_g' => 180, 'quantity_on_hand' => 12], 'ACME-VNECK'),
database/seeders/ProductSeeder.php:            $this->product('Striped Polo Shirt', 'striped-polo-shirt', 'Acme Basics', 'T-Shirts', ['sale'], ['t-shirts', 'sale'], '
Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.
', [['Size' => ['S', 'M', 'L', 'XL']]], ['price_amount' => 2799, 'compare_at_amount' => 3999, 'weight_g' => 250, 'quantity_on_hand' => 10], 'ACME-POLO'),
database/seeders/ProductSeeder.php:            $this->product('Cargo Pants', 'cargo-pants', 'Acme Workwear', 'Pants', ['popular'], ['pants-jeans'], '
Utility cargo pants with multiple pockets. Durable cotton twill construction.
', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Khaki', 'Olive', 'Black']]], ['price_amount' => 5499, 'weight_g' => 700, 'quantity_on_hand' => 14], 'ACME-CARGO'),
database/seeders/ProductSeeder.php:            $this->product('Chino Shorts', 'chino-shorts', 'Acme Basics', 'Pants', ['new', 'trending'], ['pants-jeans', 'new-arrivals'], '
Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.
', [['Size' => ['30', '32', '34', '36']], ['Color' => ['Navy', 'Sand']]], ['price_amount' => 3999, 'weight_g' => 350, 'quantity_on_hand' => 16], 'ACME-CHINO'),
database/seeders/ProductSeeder.php:            $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'Acme Denim', 'Pants', ['sale'], ['pants-jeans', 'sale'], '
Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.
', [['Size' => ['S', 'M', 'L']]], ['price_amount' => 4999, 'compare_at_amount' => 6999, 'weight_g' => 550, 'quantity_on_hand' => 7], 'ACME-WIDE'),
database/seeders/ProductSeeder.php:            $this->product('Bucket Hat', 'bucket-hat', 'Acme Accessories', 'Accessories', ['new', 'trending'], ['new-arrivals'], '
Lightweight bucket hat for sun protection. Packable design, washed cotton twill.
', [['Size' => ['S/M', 'L/XL']], ['Color' => ['Beige', 'Black', 'Olive']]], ['price_amount' => 2499, 'weight_g' => 80, 'quantity_on_hand' => 22], 'ACME-HAT'),
database/seeders/ProductSeeder.php:            $this->product('Cashmere Overcoat', 'cashmere-overcoat', 'Acme Premium', 'Jackets', ['limited', 'new'], ['new-arrivals'], '
Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.
', [['Size' => ['S', 'M', 'L']], ['Color' => ['Camel', 'Charcoal']]], ['price_amount' => 49999, 'weight_g' => 1200, 'quantity_on_hand' => 3], 'ACME-COAT'),
database/seeders/ProductSeeder.php:            $this->product('Pro Laptop 15', 'pro-laptop-15', 'TechCorp', 'Laptops', ['featured'], ['featured'], '
Performance laptop for tenant isolation tests.
', [['Storage' => ['256GB', '512GB', '1TB']]], ['price_amount' => 99999, 'weight_g' => 1800, 'quantity_on_hand' => 10], 'TECH-LAPTOP', variants: [
database/seeders/ProductSeeder.php:            $this->product('Wireless Headphones', 'wireless-headphones', 'AudioMax', 'Audio', ['featured'], ['featured'], '
Wireless headphones for tenant isolation tests.
', [['Color' => ['Black', 'Silver']]], ['price_amount' => 14999, 'weight_g' => 250, 'quantity_on_hand' => 25], 'TECH-HEADPHONES'),
database/seeders/ProductSeeder.php:            $this->product('USB-C Cable 2m', 'usb-c-cable-2m', 'CablePro', 'Cables', ['accessories'], ['accessories'], '
Two meter USB-C cable.
', [], ['price_amount' => 1299, 'weight_g' => 50, 'quantity_on_hand' => 200], 'TECH-CABLE'),
database/seeders/ProductSeeder.php:            $this->product('Mechanical Keyboard', 'mechanical-keyboard', 'KeyTech', 'Peripherals', ['featured'], ['featured'], '
Mechanical keyboard with selectable switch types.
', [['Switch Type' => ['Red', 'Blue', 'Brown']]], ['price_amount' => 12999, 'weight_g' => 1100, 'quantity_on_hand' => 15], 'TECH-KEYBOARD'),
database/seeders/ProductSeeder.php:            $this->product('Monitor Stand', 'monitor-stand', 'DeskGear', 'Accessories', ['accessories'], ['accessories'], '
Desktop monitor stand.
', [], ['price_amount' => 4999, 'weight_g' => 2500, 'quantity_on_hand' => 30], 'TECH-STAND'),


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/NavigationItemSeeder.php
@@
-    /**
-     * @param  array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>  $items
-     */
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     */
     private function replaceItems(?NavigationMenu $menu, array $items): void
     {
         if ($menu === null) {
@@
-        foreach ($items as $position => $item) {
-            NavigationItem::withoutGlobalScopes()->create([
-                'menu_id' => $menu->getKey(),
-                'type' => $item['type'],
-                'label' => $item['label'],
-                'url' => $item['url'] ?? null,
-                'resource_id' => $item['resource_id'] ?? null,
-                'position' => $position,
-            ]);
-        }
+        $this->createItems($menu, $items);
     }

     /**
-     * @return array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>
+     * @param  array<int, array<string, mixed>>  $items
      */
+    private function createItems(NavigationMenu $menu, array $items, ?int $parentId = null): void
+    {
+        foreach ($items as $position => $item) {
+            $navigationItem = NavigationItem::withoutGlobalScopes()->create([
+                'menu_id' => $menu->getKey(),
+                'parent_id' => $parentId,
+                'type' => $item['type'],
+                'label' => $item['label'],
+                'url' => $item['url'] ?? null,
+                'resource_id' => $item['resource_id'] ?? null,
+                'position' => $position,
+            ]);
+
+            if (($item['children'] ?? []) !== []) {
+                $this->createItems($menu, array_values($item['children']), $navigationItem->getKey());
+            }
+        }
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
     private function mainMenuItems(Store $store): array
     {
         $newArrivals = $this->collection($store, 'new-arrivals') ?? $this->collection($store, 'featured');
         $secondaryCollection = $this->collection($store, 't-shirts') ?? $this->collection($store, 'accessories');
+        $tertiaryCollection = $this->collection($store, 'pants-jeans');
+        $sale = $this->collection($store, 'sale');
         $about = $this->page($store, 'about');

         return array_values(array_filter([
-            ['label' => 'Collections', 'type' => 'link', 'url' => '/collections'],
-            $newArrivals ? ['label' => $newArrivals->title, 'type' => 'collection', 'resource_id' => $newArrivals->getKey()] : null,
-            $secondaryCollection ? ['label' => $secondaryCollection->title, 'type' => 'collection', 'resource_id' => $secondaryCollection->getKey()] : null,
+            ['label' => 'Home', 'type' => 'link', 'url' => '/'],
+            [
+                'label' => 'Shop',
+                'type' => 'link',
+                'url' => '/collections',
+                'children' => array_values(array_filter([
+                    $newArrivals ? ['label' => $newArrivals->title, 'type' => 'collection', 'resource_id' => $newArrivals->getKey()] : null,
+                    $secondaryCollection ? ['label' => $secondaryCollection->title, 'type' => 'collection', 'resource_id' => $secondaryCollection->getKey()] : null,
+                    $tertiaryCollection ? ['label' => $tertiaryCollection->title, 'type' => 'collection', 'resource_id' => $tertiaryCollection->getKey()] : null,
+                    $sale ? ['label' => 'Sale', 'type' => 'collection', 'resource_id' => $sale->getKey()] : null,
+                ])),
+            ],
             ['label' => 'Search', 'type' => 'link', 'url' => '/search'],
             $about ? ['label' => 'About', 'type' => 'page', 'resource_id' => $about->getKey()] : null,
         ]));
     }

     /**
-     * @return array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>
+     * @return array<int, array<string, mixed>>
      */
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/seeders/NavigationItemSeeder.php

@@ -30,3 +30,3 @@
     /**
-     * @param  array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>  $items
+     * @param  array<int, array<string, mixed>>  $items
      */
@@ -42,5 +42,14 @@

+        $this->createItems($menu, $items);
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $items
+     */
+    private function createItems(NavigationMenu $menu, array $items, ?int $parentId = null): void
+    {
         foreach ($items as $position => $item) {
-            NavigationItem::withoutGlobalScopes()->create([
+            $navigationItem = NavigationItem::withoutGlobalScopes()->create([
                 'menu_id' => $menu->getKey(),
+                'parent_id' => $parentId,
                 'type' => $item['type'],
@@ -51,2 +60,6 @@
             ]);
+
+            if (($item['children'] ?? []) !== []) {
+                $this->createItems($menu, array_values($item['children']), $navigationItem->getKey());
+            }
         }
@@ -55,3 +68,3 @@
     /**
-     * @return array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>
+     * @return array<int, array<string, mixed>>
      */
@@ -61,2 +74,4 @@
         $secondaryCollection = $this->collection($store, 't-shirts') ?? $this->collection($store, 'accessories');
+        $tertiaryCollection = $this->collection($store, 'pants-jeans');
+        $sale = $this->collection($store, 'sale');
         $about = $this->page($store, 'about');
@@ -64,5 +79,14 @@
         return array_values(array_filter([
-            ['label' => 'Collections', 'type' => 'link', 'url' => '/collections'],
-            $newArrivals ? ['label' => $newArrivals->title, 'type' => 'collection', 'resource_id' => $newArrivals->getKey()] : null,
-            $secondaryCollection ? ['label' => $secondaryCollection->title, 'type' => 'collection', 'resource_id' => $secondaryCollection->getKey()] : null,
+            ['label' => 'Home', 'type' => 'link', 'url' => '/'],
+            [
+                'label' => 'Shop',
+                'type' => 'link',
+                'url' => '/collections',
+                'children' => array_values(array_filter([
+                    $newArrivals ? ['label' => $newArrivals->title, 'type' => 'collection', 'resource_id' => $newArrivals->getKey()] : null,
+                    $secondaryCollection ? ['label' => $secondaryCollection->title, 'type' => 'collection', 'resource_id' => $secondaryCollection->getKey()] : null,
+                    $tertiaryCollection ? ['label' => $tertiaryCollection->title, 'type' => 'collection', 'resource_id' => $tertiaryCollection->getKey()] : null,
+                    $sale ? ['label' => 'Sale', 'type' => 'collection', 'resource_id' => $sale->getKey()] : null,
+                ])),
+            ],
             ['label' => 'Search', 'type' => 'link', 'url' => '/search'],
@@ -73,3 +97,3 @@
     /**
-     * @return array<int, array{label: string, type: string, url?: string|null, resource_id?: int|null}>
+     * @return array<int, array<string, mixed>>
      */


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/seeders/NavigationItemSeeder.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php
@@
 use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
 use Illuminate\Validation\Rule;
@@
-     * @var array<int, array{id: int|null, label: string, type: string, url: string|null, resource_id: int|null}>
+     * @var array<int, array{id: int|null, key: string, parent_key: string|null, label: string, type: string, url: string|null, resource_id: int|null}>
      */
     public array $menuItems = [];
@@
     public ?int $itemResourceId = null;
+
+    public string $itemParentKey = '';
@@
         $this->itemType = 'link';
         $this->itemUrl = '';
         $this->itemResourceId = null;
+        $this->itemParentKey = '';
     }
@@
         $this->itemType = $item['type'];
         $this->itemUrl = (string) ($item['url'] ?? '');
         $this->itemResourceId = $item['resource_id'];
+        $this->itemParentKey = (string) ($item['parent_key'] ?? '');
     }
@@
             'itemType' => ['required', Rule::in(array_column(NavigationItemType::cases(), 'value'))],
             'itemUrl' => [Rule::requiredIf($this->itemType === NavigationItemType::Link->value), 'nullable', 'string', 'max:255'],
             'itemResourceId' => [Rule::requiredIf($this->itemType !== NavigationItemType::Link->value), 'nullable', 'integer'],
+            'itemParentKey' => ['nullable', 'string'],
         ], [], [
             'itemLabel' => 'label',
             'itemType' => 'type',
             'itemUrl' => 'URL',
             'itemResourceId' => 'resource',
+            'itemParentKey' => 'parent item',
         ]);
+
+        $parentKey = $this->normalizedParentKey($validated['itemParentKey']);
+
+        if (! $this->validParentKey($parentKey)) {
+            $this->addError('itemParentKey', __('Select a valid top-level parent item.'));
+
+            return;
+        }
+
+        if ($this->editingItemIndex !== null
+            && $parentKey !== null
+            && $this->itemHasChildren($this->menuItems[$this->editingItemIndex]['key'])) {
+            $this->addError('itemParentKey', __('Items with children must stay top-level.'));
+
+            return;
+        }
@@
         $item = [
             'id' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['id'] : null,
+            'key' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : 'new-'.Str::uuid()->toString(),
+            'parent_key' => $parentKey,
             'label' => $validated['itemLabel'],
             'type' => $validated['itemType'],
             'url' => $this->itemType === NavigationItemType::Link->value ? $validated['itemUrl'] : null,
@@
     public function moveItemUp(int $index): void
     {
         if ($index <= 0 || ! isset($this->menuItems[$index])) {
             return;
         }

-        [$this->menuItems[$index - 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index - 1]];
+        $this->moveSibling($index, -1);
     }

     public function moveItemDown(int $index): void
     {
-        if (! isset($this->menuItems[$index], $this->menuItems[$index + 1])) {
+        if (! isset($this->menuItems[$index])) {
             return;
         }

-        [$this->menuItems[$index], $this->menuItems[$index + 1]] = [$this->menuItems[$index + 1], $this->menuItems[$index]];
+        $this->moveSibling($index, 1);
+    }
+
+    public function reorderItem(string $key, int $position, string $parentKey = 'root'): void
+    {
+        $targetParentKey = $this->normalizedParentKey($parentKey === 'root' ? '' : $parentKey);
+
+        if (! $this->validParentKey($targetParentKey, $key) || ($targetParentKey !== null && $this->itemHasChildren($key))) {
+            return;
+        }
+
+        $index = $this->itemIndexForKey($key);
+
+        if ($index === null) {
+            return;
+        }
+
+        $item = $this->menuItems[$index];
+        array_splice($this->menuItems, $index, 1);
+
+        $item['parent_key'] = $targetParentKey;
+        $siblingIndexes = $this->siblingIndices($targetParentKey);
+        $insertAt = $siblingIndexes[$position] ?? (count($this->menuItems));
+
+        if ($position >= count($siblingIndexes) && $siblingIndexes !== []) {
+            $insertAt = ((int) end($siblingIndexes)) + 1;
+        }
+
+        array_splice($this->menuItems, $insertAt, 0, [$item]);
+        $this->menuItems = array_values($this->menuItems);
     }
@@
-            foreach ($this->menuItems as $position => $item) {
-                NavigationItem::withoutGlobalScopes()->create([
-                    'menu_id' => $menu->getKey(),
-                    'type' => NavigationItemType::from($item['type']),
-                    'label' => $item['label'],
-                    'url' => $item['url'],
-                    'resource_id' => $item['resource_id'],
-                    'position' => $position,
-                ]);
-            }
+            $this->persistMenuItems($menu);
         });
@@
         $this->itemType = 'link';
         $this->itemUrl = '';
         $this->itemResourceId = null;
-        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId']);
+        $this->itemParentKey = '';
+        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId', 'itemParentKey']);
@@
     public function targetLabel(array $item): string
     {
@@
     }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    public function nestedMenuItems(): array
+    {
+        $itemsByParent = collect($this->menuItems)
+            ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root', preserveKeys: true);
+
+        $build = function (string $parentKey) use (&$build, $itemsByParent): array {
+            return $itemsByParent->get($parentKey, collect())
+                ->map(fn (array $item, int $index): array => array_merge($item, [
+                    'index' => $index,
+                    'children' => $build($item['key']),
+                ]))
+                ->values()
+                ->all();
+        };
+
+        return $build('root');
+    }
+
+    /**
+     * @return array<int, array{key: string, label: string}>
+     */
+    public function parentOptions(): array
+    {
+        $editingKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null;
+
+        return collect($this->menuItems)
+            ->filter(fn (array $item): bool => ($item['parent_key'] ?? null) === null && $item['key'] !== $editingKey)
+            ->map(fn (array $item): array => [
+                'key' => $item['key'],
+                'label' => $item['label'],
+            ])
+            ->values()
+            ->all();
+    }
@@
             'menus' => $this->menus(),
             'selectedMenu' => $this->selectedMenu(),
+            'navigationTree' => $this->nestedMenuItems(),
+            'parentOptions' => $this->parentOptions(),
             'resources' => $this->resourcesForType(),
         ])->layout('layouts.app', [
@@
             ->orderBy('position')
             ->get()
             ->map(fn (NavigationItem $item): array => [
                 'id' => $item->getKey(),
+                'key' => (string) $item->getKey(),
+                'parent_key' => $item->parent_id === null ? null : (string) $item->parent_id,
                 'label' => $item->label,
                 'type' => $item->type->value,
                 'url' => $item->url,
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php

@@ -15,2 +15,3 @@
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
 use Illuminate\Validation\Rule;
@@ -29,3 +30,3 @@
     /**
-     * @var array<int, array{id: int|null, label: string, type: string, url: string|null, resource_id: int|null}>
+     * @var array<int, array{id: int|null, key: string, parent_key: string|null, label: string, type: string, url: string|null, resource_id: int|null}>
      */
@@ -43,2 +44,4 @@

+    public string $itemParentKey = '';
+
     public function mount(): void
@@ -73,2 +76,3 @@
         $this->itemResourceId = null;
+        $this->itemParentKey = '';
     }
@@ -86,2 +90,3 @@
         $this->itemResourceId = $item['resource_id'];
+        $this->itemParentKey = (string) ($item['parent_key'] ?? '');
     }
@@ -97,2 +102,3 @@
             'itemResourceId' => [Rule::requiredIf($this->itemType !== NavigationItemType::Link->value), 'nullable', 'integer'],
+            'itemParentKey' => ['nullable', 'string'],
         ], [], [
@@ -102,4 +108,21 @@
             'itemResourceId' => 'resource',
+            'itemParentKey' => 'parent item',
         ]);

+        $parentKey = $this->normalizedParentKey($validated['itemParentKey']);
+
+        if (! $this->validParentKey($parentKey)) {
+            $this->addError('itemParentKey', __('Select a valid top-level parent item.'));
+
+            return;
+        }
+
+        if ($this->editingItemIndex !== null
+            && $parentKey !== null
+            && $this->itemHasChildren($this->menuItems[$this->editingItemIndex]['key'])) {
+            $this->addError('itemParentKey', __('Items with children must stay top-level.'));
+
+            return;
+        }
+
         if ($this->itemType !== NavigationItemType::Link->value && ! $this->resourceExists($this->itemType, (int) $this->itemResourceId)) {
@@ -112,2 +135,4 @@
             'id' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['id'] : null,
+            'key' => $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : 'new-'.Str::uuid()->toString(),
+            'parent_key' => $parentKey,
             'label' => $validated['itemLabel'],
@@ -140,3 +165,3 @@

-        [$this->menuItems[$index - 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index - 1]];
+        $this->moveSibling($index, -1);
     }
@@ -145,3 +170,3 @@
     {
-        if (! isset($this->menuItems[$index], $this->menuItems[$index + 1])) {
+        if (! isset($this->menuItems[$index])) {
             return;
@@ -149,3 +174,32 @@

-        [$this->menuItems[$index], $this->menuItems[$index + 1]] = [$this->menuItems[$index + 1], $this->menuItems[$index]];
+        $this->moveSibling($index, 1);
+    }
+
+    public function reorderItem(string $key, int $position, string $parentKey = 'root'): void
+    {
+        $targetParentKey = $this->normalizedParentKey($parentKey === 'root' ? '' : $parentKey);
+
+        if (! $this->validParentKey($targetParentKey, $key) || ($targetParentKey !== null && $this->itemHasChildren($key))) {
+            return;
+        }
+
+        $index = $this->itemIndexForKey($key);
+
+        if ($index === null) {
+            return;
+        }
+
+        $item = $this->menuItems[$index];
+        array_splice($this->menuItems, $index, 1);
+
+        $item['parent_key'] = $targetParentKey;
+        $siblingIndexes = $this->siblingIndices($targetParentKey);
+        $insertAt = $siblingIndexes[$position] ?? (count($this->menuItems));
+
+        if ($position >= count($siblingIndexes) && $siblingIndexes !== []) {
+            $insertAt = ((int) end($siblingIndexes)) + 1;
+        }
+
+        array_splice($this->menuItems, $insertAt, 0, [$item]);
+        $this->menuItems = array_values($this->menuItems);
     }
@@ -163,12 +217,3 @@

-            foreach ($this->menuItems as $position => $item) {
-                NavigationItem::withoutGlobalScopes()->create([
-                    'menu_id' => $menu->getKey(),
-                    'type' => NavigationItemType::from($item['type']),
-                    'label' => $item['label'],
-                    'url' => $item['url'],
-                    'resource_id' => $item['resource_id'],
-                    'position' => $position,
-                ]);
-            }
+            $this->persistMenuItems($menu);
         });
@@ -189,3 +234,4 @@
         $this->itemResourceId = null;
-        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId']);
+        $this->itemParentKey = '';
+        $this->resetErrorBag(['itemLabel', 'itemType', 'itemUrl', 'itemResourceId', 'itemParentKey']);
     }
@@ -237,2 +283,40 @@

+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    public function nestedMenuItems(): array
+    {
+        $itemsByParent = collect($this->menuItems)
+            ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root', preserveKeys: true);
+
+        $build = function (string $parentKey) use (&$build, $itemsByParent): array {
+            return $itemsByParent->get($parentKey, collect())
+                ->map(fn (array $item, int $index): array => array_merge($item, [
+                    'index' => $index,
+                    'children' => $build($item['key']),
+                ]))
+                ->values()
+                ->all();
+        };
+
+        return $build('root');
+    }
+
+    /**
+     * @return array<int, array{key: string, label: string}>
+     */
+    public function parentOptions(): array
+    {
+        $editingKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null;
+
+        return collect($this->menuItems)
+            ->filter(fn (array $item): bool => ($item['parent_key'] ?? null) === null && $item['key'] !== $editingKey)
+            ->map(fn (array $item): array => [
+                'key' => $item['key'],
+                'label' => $item['label'],
+            ])
+            ->values()
+            ->all();
+    }
+
     public function render(): mixed
@@ -242,2 +326,4 @@
             'selectedMenu' => $this->selectedMenu(),
+            'navigationTree' => $this->nestedMenuItems(),
+            'parentOptions' => $this->parentOptions(),
             'resources' => $this->resourcesForType(),
@@ -285,2 +371,4 @@
                 'id' => $item->getKey(),
+                'key' => (string) $item->getKey(),
+                'parent_key' => $item->parent_id === null ? null : (string) $item->parent_id,
                 'label' => $item->label,


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php
@@
-        if (! $this->validParentKey($parentKey)) {
+        $currentKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null;
+
+        if (! $this->validParentKey($parentKey, $currentKey)) {
             $this->addError('itemParentKey', __('Select a valid top-level parent item.'));

             return;
@@
     public function moveItemUp(int $index): void
     {
-        if ($index <= 0 || ! isset($this->menuItems[$index])) {
+        if (! isset($this->menuItems[$index])) {
             return;
         }
@@
     private function resourceTitle(string $modelClass, mixed $resourceId): string
     {
         if (! is_numeric($resourceId)) {
             return 'Missing';
         }
@@
             ->whereKey((int) $resourceId)
             ->value('title') ?? 'Missing';
     }
+
+    private function persistMenuItems(NavigationMenu $menu): void
+    {
+        $itemsByParent = collect($this->menuItems)
+            ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root');
+
+        $persist = function (string $parentKey, ?int $parentId = null) use (&$persist, $itemsByParent, $menu): void {
+            $itemsByParent->get($parentKey, collect())
+                ->values()
+                ->each(function (array $item, int $position) use (&$persist, $menu, $parentId): void {
+                    $navigationItem = NavigationItem::withoutGlobalScopes()->create([
+                        'menu_id' => $menu->getKey(),
+                        'parent_id' => $parentId,
+                        'type' => NavigationItemType::from($item['type']),
+                        'label' => $item['label'],
+                        'url' => $item['url'],
+                        'resource_id' => $item['resource_id'],
+                        'position' => $position,
+                    ]);
+
+                    $persist($item['key'], $navigationItem->getKey());
+                });
+        };
+
+        $persist('root');
+    }
+
+    private function moveSibling(int $index, int $direction): void
+    {
+        $parentKey = $this->menuItems[$index]['parent_key'] ?? null;
+        $siblingIndices = $this->siblingIndices($parentKey);
+        $siblingPosition = array_search($index, $siblingIndices, true);
+
+        if ($siblingPosition === false) {
+            return;
+        }
+
+        $targetSiblingPosition = $siblingPosition + $direction;
+
+        if (! isset($siblingIndices[$targetSiblingPosition])) {
+            return;
+        }
+
+        $targetIndex = $siblingIndices[$targetSiblingPosition];
+        [$this->menuItems[$index], $this->menuItems[$targetIndex]] = [$this->menuItems[$targetIndex], $this->menuItems[$index]];
+        $this->menuItems = array_values($this->menuItems);
+    }
+
+    /**
+     * @return list<int>
+     */
+    private function siblingIndices(?string $parentKey): array
+    {
+        return array_values(array_keys(array_filter(
+            $this->menuItems,
+            fn (array $item): bool => ($item['parent_key'] ?? null) === $parentKey,
+        )));
+    }
+
+    private function normalizedParentKey(?string $parentKey): ?string
+    {
+        $parentKey = trim((string) $parentKey);
+
+        return $parentKey === '' ? null : $parentKey;
+    }
+
+    private function validParentKey(?string $parentKey, ?string $movingKey = null): bool
+    {
+        if ($parentKey === null) {
+            return true;
+        }
+
+        foreach ($this->menuItems as $item) {
+            if ($item['key'] === $parentKey
+                && ($item['parent_key'] ?? null) === null
+                && $item['key'] !== $movingKey) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function itemHasChildren(string $key): bool
+    {
+        foreach ($this->menuItems as $item) {
+            if (($item['parent_key'] ?? null) === $key) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function itemIndexForKey(string $key): ?int
+    {
+        foreach ($this->menuItems as $index => $item) {
+            if ($item['key'] === $key) {
+                return $index;
+            }
+        }
+
+        return null;
+    }
 }
*** End Patch


@@ -113,3 +113,5 @@

-        if (! $this->validParentKey($parentKey)) {
+        $currentKey = $this->editingItemIndex !== null ? $this->menuItems[$this->editingItemIndex]['key'] : null;
+
+        if (! $this->validParentKey($parentKey, $currentKey)) {
             $this->addError('itemParentKey', __('Select a valid top-level parent item.'));
@@ -161,3 +163,3 @@
     {
-        if ($index <= 0 || ! isset($this->menuItems[$index])) {
+        if (! isset($this->menuItems[$index])) {
             return;
@@ -402,2 +404,106 @@
     }
+
+    private function persistMenuItems(NavigationMenu $menu): void
+    {
+        $itemsByParent = collect($this->menuItems)
+            ->groupBy(fn (array $item): string => $item['parent_key'] ?? 'root');
+
+        $persist = function (string $parentKey, ?int $parentId = null) use (&$persist, $itemsByParent, $menu): void {
+            $itemsByParent->get($parentKey, collect())
+                ->values()
+                ->each(function (array $item, int $position) use (&$persist, $menu, $parentId): void {
+                    $navigationItem = NavigationItem::withoutGlobalScopes()->create([
+                        'menu_id' => $menu->getKey(),
+                        'parent_id' => $parentId,
+                        'type' => NavigationItemType::from($item['type']),
+                        'label' => $item['label'],
+                        'url' => $item['url'],
+                        'resource_id' => $item['resource_id'],
+                        'position' => $position,
+                    ]);
+
+                    $persist($item['key'], $navigationItem->getKey());
+                });
+        };
+
+        $persist('root');
+    }
+
+    private function moveSibling(int $index, int $direction): void
+    {
+        $parentKey = $this->menuItems[$index]['parent_key'] ?? null;
+        $siblingIndices = $this->siblingIndices($parentKey);
+        $siblingPosition = array_search($index, $siblingIndices, true);
+
+        if ($siblingPosition === false) {
+            return;
+        }
+
+        $targetSiblingPosition = $siblingPosition + $direction;
+
+        if (! isset($siblingIndices[$targetSiblingPosition])) {
+            return;
+        }
+
+        $targetIndex = $siblingIndices[$targetSiblingPosition];
+        [$this->menuItems[$index], $this->menuItems[$targetIndex]] = [$this->menuItems[$targetIndex], $this->menuItems[$index]];
+        $this->menuItems = array_values($this->menuItems);
+    }
+
+    /**
+     * @return list<int>
+     */
+    private function siblingIndices(?string $parentKey): array
+    {
+        return array_values(array_keys(array_filter(
+            $this->menuItems,
+            fn (array $item): bool => ($item['parent_key'] ?? null) === $parentKey,
+        )));
+    }
+
+    private function normalizedParentKey(?string $parentKey): ?string
+    {
+        $parentKey = trim((string) $parentKey);
+
+        return $parentKey === '' ? null : $parentKey;
+    }
+
+    private function validParentKey(?string $parentKey, ?string $movingKey = null): bool
+    {
+        if ($parentKey === null) {
+            return true;
+        }
+
+        foreach ($this->menuItems as $item) {
+            if ($item['key'] === $parentKey
+                && ($item['parent_key'] ?? null) === null
+                && $item['key'] !== $movingKey) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function itemHasChildren(string $key): bool
+    {
+        foreach ($this->menuItems as $item) {
+            if (($item['parent_key'] ?? null) === $key) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function itemIndexForKey(string $key): ?int
+    {
+        foreach ($this->menuItems as $index => $item) {
+            if ($item['key'] === $key) {
+                return $index;
+            }
+        }
+
+        return null;
+    }
 }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Navigation/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}