sed -n '1,220p' app/Models/Theme.php && sed -n '1,220p' app/Models/ThemeFile.php && sed -n '1,220p' app/Models/ThemeSettings.php

ThemeSettings.php

<?php

namespace App\Models;

use App\Enums\ThemeStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Theme extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'name',
        'version',
        'status',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ThemeFile, $this>
     */
    public function files(): HasMany
    {
        return $this->hasMany(ThemeFile::class)->orderBy('path');
    }

    /**
     * @return HasOne<ThemeSettings, $this>
     */
    public function settings(): HasOne
    {
        return $this->hasOne(ThemeSettings::class);
    }

    public function isPublished(): bool
    {
        return $this->status === ThemeStatus::Published && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ThemeStatus::class,
            'published_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ThemeFile extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeFileFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'theme_id',
        'path',
        'storage_key',
        'sha256',
        'byte_size',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'byte_size' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('theme', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Theme, $this>
     */
    public function theme(): BelongsTo
    {
        return $this->belongsTo(Theme::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'byte_size' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ThemeSettings extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeSettingsFactory> */
    use HasFactory;

    public $incrementing = false;

    public const CREATED_AT = null;

    protected $primaryKey = 'theme_id';

    /**
     * @var list<string>
     */
    protected $fillable = [
        'theme_id',
        'settings_json',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('theme', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Theme, $this>
     */
    public function theme(): BelongsTo
    {
        return $this->belongsTo(Theme::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'settings_json' => 'array',
        ];
    }
}


sed -n '1,260p' database/seeders/ThemeSeeder.php && sed -n '1,260p' database/seeders/ThemeFileSeeder.php && sed -n '1,260p' tests/Feature/Admin/ContentManagementTest.php

ContentManagementTest.php

<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\Theme;
use Illuminate\Database\Seeder;

class ThemeSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Store::query()->orderBy('id')->get()->each(function (Store $store): void {
            Theme::withoutGlobalScopes()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'name' => "{$store->name} Default",
                ],
                [
                    'version' => '1.0.0',
                    'status' => 'published',
                    'published_at' => now(),
                ],
            );
        });
    }
}
<?php

namespace Database\Seeders;

use App\Models\Theme;
use App\Models\ThemeFile;
use Illuminate\Database\Seeder;

class ThemeFileSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Theme::withoutGlobalScopes()->get()->each(function (Theme $theme): void {
            foreach ([
                'layouts/storefront.blade.php',
                'sections/hero.blade.php',
                'sections/featured-products.blade.php',
            ] as $path) {
                ThemeFile::withoutGlobalScopes()->updateOrCreate(
                    [
                        'theme_id' => $theme->getKey(),
                        'path' => $path,
                    ],
                    [
                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
                        'sha256' => hash('sha256', "{$theme->getKey()}:{$path}"),
                        'byte_size' => 1024,
                    ],
                );
            }
        });
    }
}
<?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 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();

    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('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();
});


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

namespace App\Models;

use App\Enums\ThemeStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Theme extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'name',
        'version',
        'status',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ThemeFile, $this>
     */
    public function files(): HasMany
    {
        return $this->hasMany(ThemeFile::class)->orderBy('path');
    }

    /**
     * @return HasOne<ThemeSettings, $this>
     */
    public function settings(): HasOne
    {
        return $this->hasOne(ThemeSettings::class);
    }

    public function isPublished(): bool
    {
        return $this->status === ThemeStatus::Published && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ThemeStatus::class,
            'published_at' => 'datetime',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ThemeFile extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeFileFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'theme_id',
        'path',
        'storage_key',
        'sha256',
        'byte_size',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'byte_size' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('theme', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Theme, $this>
     */
    public function theme(): BelongsTo
    {
        return $this->belongsTo(Theme::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'byte_size' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ThemeSettings extends Model
{
    /** @use HasFactory<\Database\Factories\ThemeSettingsFactory> */
    use HasFactory;

    public $incrementing = false;

    public const CREATED_AT = null;

    protected $primaryKey = 'theme_id';

    /**
     * @var list<string>
     */
    protected $fillable = [
        'theme_id',
        'settings_json',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('theme', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Theme, $this>
     */
    public function theme(): BelongsTo
    {
        return $this->belongsTo(Theme::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'settings_json' => 'array',
        ];
    }
}


Output:
Chunk ID: 1fb9a0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2362
Output:
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\Theme;
use Illuminate\Database\Seeder;

class ThemeSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Store::query()->orderBy('id')->get()->each(function (Store $store): void {
            Theme::withoutGlobalScopes()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'name' => "{$store->name} Default",
                ],
                [
                    'version' => '1.0.0',
                    'status' => 'published',
                    'published_at' => now(),
                ],
            );
        });
    }
}
<?php

namespace Database\Seeders;

use App\Models\Theme;
use App\Models\ThemeFile;
use Illuminate\Database\Seeder;

class ThemeFileSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Theme::withoutGlobalScopes()->get()->each(function (Theme $theme): void {
            foreach ([
                'layouts/storefront.blade.php',
                'sections/hero.blade.php',
                'sections/featured-products.blade.php',
            ] as $path) {
                ThemeFile::withoutGlobalScopes()->updateOrCreate(
                    [
                        'theme_id' => $theme->getKey(),
                        'path' => $path,
                    ],
                    [
                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
                        'sha256' => hash('sha256', "{$theme->getKey()}:{$path}"),
                        'byte_size' => 1024,
                    ],
                );
            }
        });
    }
}
<?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 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();

    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('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();
});


sed -n '388,430p' specs/01-DATABASE-SCHEMA.md && rg -n "storage_key|theme files|ThemeFile|themes/" app database tests config/filesystems.php


sed -n '388,430p' specs/01-DATABASE-SCHEMA.md && rg -n "storage_key|theme files|ThemeFile|themes/" app database tests config/filesystems.php

rg -n 'storage_key|theme files|ThemeFile|themes/' app database tests config/filesystems.php

Question: storage_key|theme files|ThemeFile|themes/

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
database/seeders/DatabaseSeeder.php:26:            ThemeFileSeeder::class,
database/factories/ProductMediaFactory.php:25:            'storage_key' => 'products/'.fake()->uuid().'.jpg',
database/factories/ProductMediaFactory.php:47:            'storage_key' => 'products/'.fake()->uuid().'.mp4',
tests/Feature/Admin/ContentManagementTest.php:92:        ->get('/admin/themes/'.$theme->getKey().'/editor')
database/factories/ThemeFileFactory.php:10: * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ThemeFile>
database/factories/ThemeFileFactory.php:12:class ThemeFileFactory extends Factory
database/factories/ThemeFileFactory.php:26:            'storage_key' => 'themes/'.Str::random(12).'/'.$path,
tests/Feature/Admin/ProductMediaManagementTest.php:58:        ->and($media->storage_key)->toStartWith("media/originals/{$product->getKey()}/");
tests/Feature/Admin/ProductMediaManagementTest.php:60:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:98:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:135:            'storage_key' => "media/originals/{$product->getKey()}/{$position}.png",
tests/Feature/Admin/ProductMediaManagementTest.php:140:        Storage::disk('public')->put($item->storage_key, 'image-bytes');
tests/Feature/Admin/ProductMediaManagementTest.php:164:    Storage::disk('public')->assertMissing($media[2]->storage_key);
database/migrations/2026_05_03_223830_create_theme_files_table.php:18:            $table->string('storage_key');
app/Services/SearchService.php:78:                    'image_url' => $product->media->first()?->storage_key,
database/migrations/2026_05_03_213904_create_product_media_table.php:18:            $table->string('storage_key');
tests/Feature/Catalog/MediaProcessingTest.php:20:        'storage_key' => 'media/originals/test.png',
tests/Feature/Catalog/MediaProcessingTest.php:27:    Storage::disk('public')->put($media->storage_key, catalogPngImageContents());
tests/Feature/Catalog/MediaProcessingTest.php:60:        'storage_key' => 'media/originals/not-image.txt',
tests/Feature/Catalog/MediaProcessingTest.php:63:    Storage::disk('public')->put($media->storage_key, 'not an image');
app/Jobs/ProcessMediaUpload.php:50:                'storage_key' => $media->storage_key,
app/Jobs/ProcessMediaUpload.php:77:        if (! $disk->exists($media->storage_key)) {
app/Jobs/ProcessMediaUpload.php:81:        $contents = $disk->get($media->storage_key);
app/Jobs/ProcessMediaUpload.php:83:        $byteSize = $disk->size($media->storage_key);
app/Livewire/Admin/Themes/Index.php:8:use App\Models\ThemeFile;
app/Livewire/Admin/Themes/Index.php:79:                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
app/Livewire/Admin/Themes/Index.php:82:                    'storage_key' => $file->storage_key,
tests/Feature/Storefront/ThemeDataTest.php:9:use App\Models\ThemeFile;
tests/Feature/Storefront/ThemeDataTest.php:28:        ->and(Schema::hasColumns('theme_files', ['theme_id', 'path', 'storage_key', 'sha256', 'byte_size']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:34:        ->and(ThemeFile::withoutGlobalScopes()->count())->toBe(6)
tests/Feature/Storefront/ThemeDataTest.php:67:    ThemeFile::factory()->create(['theme_id' => $firstTheme->getKey(), 'path' => 'first.blade.php']);
tests/Feature/Storefront/ThemeDataTest.php:68:    ThemeFile::factory()->create(['theme_id' => $secondTheme->getKey(), 'path' => 'second.blade.php']);
tests/Feature/Storefront/ThemeDataTest.php:76:    expect(ThemeFile::query()->pluck('path')->all())->toBe(['first.blade.php'])
database/seeders/ThemeFileSeeder.php:6:use App\Models\ThemeFile;
database/seeders/ThemeFileSeeder.php:9:class ThemeFileSeeder extends Seeder
database/seeders/ThemeFileSeeder.php:22:                ThemeFile::withoutGlobalScopes()->updateOrCreate(
database/seeders/ThemeFileSeeder.php:28:                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
app/Livewire/Admin/Products/Form.php:380:                'url' => Storage::disk('public')->url($media->storage_key),
app/Livewire/Admin/Products/Form.php:381:                'exists' => Storage::disk('public')->exists($media->storage_key),
app/Livewire/Admin/Products/Form.php:759:                'storage_key' => $storageKey,
app/Http/Resources/Storefront/V1/SearchProductResource.php:32:            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
app/Models/ProductMedia.php:26:        'storage_key',
app/Models/ProductMedia.php:68:            $disk->delete($media->storage_key);
app/Models/ThemeFile.php:10:class ThemeFile extends Model
app/Models/ThemeFile.php:12:    /** @use HasFactory<\Database\Factories\ThemeFileFactory> */
app/Models/ThemeFile.php:23:        'storage_key',
app/Models/Theme.php:45:     * @return HasMany<ThemeFile, $this>
app/Models/Theme.php:49:        return $this->hasMany(ThemeFile::class)->orderBy('path');


Output:
Chunk ID: b1ce12
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1629
Output:

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
database/seeders/DatabaseSeeder.php:26:            ThemeFileSeeder::class,
database/factories/ProductMediaFactory.php:25:            'storage_key' => 'products/'.fake()->uuid().'.jpg',
database/factories/ProductMediaFactory.php:47:            'storage_key' => 'products/'.fake()->uuid().'.mp4',
tests/Feature/Admin/ContentManagementTest.php:92:        ->get('/admin/themes/'.$theme->getKey().'/editor')
database/factories/ThemeFileFactory.php:10: * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ThemeFile>
database/factories/ThemeFileFactory.php:12:class ThemeFileFactory extends Factory
database/factories/ThemeFileFactory.php:26:            'storage_key' => 'themes/'.Str::random(12).'/'.$path,
tests/Feature/Admin/ProductMediaManagementTest.php:58:        ->and($media->storage_key)->toStartWith("media/originals/{$product->getKey()}/");
tests/Feature/Admin/ProductMediaManagementTest.php:60:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:98:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:135:            'storage_key' => "media/originals/{$product->getKey()}/{$position}.png",
tests/Feature/Admin/ProductMediaManagementTest.php:140:        Storage::disk('public')->put($item->storage_key, 'image-bytes');
tests/Feature/Admin/ProductMediaManagementTest.php:164:    Storage::disk('public')->assertMissing($media[2]->storage_key);
database/migrations/2026_05_03_223830_create_theme_files_table.php:18:            $table->string('storage_key');
app/Services/SearchService.php:78:                    'image_url' => $product->media->first()?->storage_key,
database/migrations/2026_05_03_213904_create_product_media_table.php:18:            $table->string('storage_key');
tests/Feature/Catalog/MediaProcessingTest.php:20:        'storage_key' => 'media/originals/test.png',
tests/Feature/Catalog/MediaProcessingTest.php:27:    Storage::disk('public')->put($media->storage_key, catalogPngImageContents());
tests/Feature/Catalog/MediaProcessingTest.php:60:        'storage_key' => 'media/originals/not-image.txt',
tests/Feature/Catalog/MediaProcessingTest.php:63:    Storage::disk('public')->put($media->storage_key, 'not an image');
app/Jobs/ProcessMediaUpload.php:50:                'storage_key' => $media->storage_key,
app/Jobs/ProcessMediaUpload.php:77:        if (! $disk->exists($media->storage_key)) {
app/Jobs/ProcessMediaUpload.php:81:        $contents = $disk->get($media->storage_key);
app/Jobs/ProcessMediaUpload.php:83:        $byteSize = $disk->size($media->storage_key);
app/Livewire/Admin/Themes/Index.php:8:use App\Models\ThemeFile;
app/Livewire/Admin/Themes/Index.php:79:                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
app/Livewire/Admin/Themes/Index.php:82:                    'storage_key' => $file->storage_key,
tests/Feature/Storefront/ThemeDataTest.php:9:use App\Models\ThemeFile;
tests/Feature/Storefront/ThemeDataTest.php:28:        ->and(Schema::hasColumns('theme_files', ['theme_id', 'path', 'storage_key', 'sha256', 'byte_size']))->toBeTrue()
tests/Feature/Storefront/ThemeDataTest.php:34:        ->and(ThemeFile::withoutGlobalScopes()->count())->toBe(6)
tests/Feature/Storefront/ThemeDataTest.php:67:    ThemeFile::factory()->create(['theme_id' => $firstTheme->getKey(), 'path' => 'first.blade.php']);
tests/Feature/Storefront/ThemeDataTest.php:68:    ThemeFile::factory()->create(['theme_id' => $secondTheme->getKey(), 'path' => 'second.blade.php']);
tests/Feature/Storefront/ThemeDataTest.php:76:    expect(ThemeFile::query()->pluck('path')->all())->toBe(['first.blade.php'])
database/seeders/ThemeFileSeeder.php:6:use App\Models\ThemeFile;
database/seeders/ThemeFileSeeder.php:9:class ThemeFileSeeder extends Seeder
database/seeders/ThemeFileSeeder.php:22:                ThemeFile::withoutGlobalScopes()->updateOrCreate(
database/seeders/ThemeFileSeeder.php:28:                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
app/Livewire/Admin/Products/Form.php:380:                'url' => Storage::disk('public')->url($media->storage_key),
app/Livewire/Admin/Products/Form.php:381:                'exists' => Storage::disk('public')->exists($media->storage_key),
app/Livewire/Admin/Products/Form.php:759:                'storage_key' => $storageKey,
app/Http/Resources/Storefront/V1/SearchProductResource.php:32:            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
app/Models/ProductMedia.php:26:        'storage_key',
app/Models/ProductMedia.php:68:            $disk->delete($media->storage_key);
app/Models/ThemeFile.php:10:class ThemeFile extends Model
app/Models/ThemeFile.php:12:    /** @use HasFactory<\Database\Factories\ThemeFileFactory> */
app/Models/ThemeFile.php:23:        'storage_key',
app/Models/Theme.php:45:     * @return HasMany<ThemeFile, $this>
app/Models/Theme.php:49:        return $this->hasMany(ThemeFile::class)->orderBy('path');


sed -n '1,150p' app/Livewire/Admin/Themes/Index.php


sed -n '1,150p' app/Livewire/Admin/Themes/Index.php

<?php

namespace App\Livewire\Admin\Themes;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Theme::class);

        $this->storeId = $store->getKey();
    }

    public function publishTheme(int $themeId, ThemeSettingsService $settings): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('publish', $theme);

        DB::transaction(function () use ($theme): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $settings->forget($this->store());

        session()->flash('status', 'Theme published');
        $this->dispatch('toast', type: 'success', message: __('Theme published'));
    }

    public function duplicateTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('create', Theme::class);

        DB::transaction(function () use ($theme): void {
            $copy = Theme::withoutGlobalScopes()->create([
                'store_id' => $this->storeId,
                'name' => $theme->name.' Copy',
                'version' => $theme->version,
                'status' => ThemeStatus::Draft,
                'published_at' => null,
            ]);

            $theme->files()
                ->withoutGlobalScopes()
                ->get()
                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
                    'theme_id' => $copy->getKey(),
                    'path' => $file->path,
                    'storage_key' => $file->storage_key,
                    'sha256' => $file->sha256,
                    'byte_size' => $file->byte_size,
                ]));

            ThemeSettings::withoutGlobalScopes()->create([
                'theme_id' => $copy->getKey(),
                'settings_json' => $theme->settings?->settings_json ?? [],
                'updated_at' => now(),
            ]);
        });

        session()->flash('status', 'Theme duplicated');
    }

    public function deleteTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('delete', $theme);

        if ($theme->isPublished()) {
            $this->addError('theme', __('Published themes cannot be deleted.'));

            return;
        }

        $theme->delete();

        session()->flash('status', 'Theme deleted');
    }

    /**
     * @return Collection<int, Theme>
     */
    public function themes(): Collection
    {
        return Theme::withoutGlobalScopes()
            ->withCount('files')
            ->where('store_id', $this->storeId)
            ->orderByRaw("case when status = 'published' then 0 else 1 end")
            ->orderBy('name')
            ->get();
    }

    public function statusColor(Theme $theme): string
    {
        return $theme->status === ThemeStatus::Published ? 'green' : 'zinc';
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.index', [
            'themes' => $this->themes(),
        ])->layout('layouts.app', [
            'title' => __('Themes'),
        ]);
    }

    private function store(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function theme(int $themeId): Theme
    {
        return Theme::withoutGlobalScopes()
            ->with(['files', 'settings'])
            ->where('store_id', $this->storeId)


Output:
Chunk ID: 4e9ce8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1069
Output:
<?php

namespace App\Livewire\Admin\Themes;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeFile;
use App\Models\ThemeSettings;
use App\Services\ThemeSettingsService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Theme::class);

        $this->storeId = $store->getKey();
    }

    public function publishTheme(int $themeId, ThemeSettingsService $settings): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('publish', $theme);

        DB::transaction(function () use ($theme): void {
            Theme::withoutGlobalScopes()
                ->where('store_id', $this->storeId)
                ->update([
                    'status' => ThemeStatus::Draft,
                    'published_at' => null,
                ]);

            $theme->forceFill([
                'status' => ThemeStatus::Published,
                'published_at' => now(),
            ])->save();
        });

        $settings->forget($this->store());

        session()->flash('status', 'Theme published');
        $this->dispatch('toast', type: 'success', message: __('Theme published'));
    }

    public function duplicateTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('create', Theme::class);

        DB::transaction(function () use ($theme): void {
            $copy = Theme::withoutGlobalScopes()->create([
                'store_id' => $this->storeId,
                'name' => $theme->name.' Copy',
                'version' => $theme->version,
                'status' => ThemeStatus::Draft,
                'published_at' => null,
            ]);

            $theme->files()
                ->withoutGlobalScopes()
                ->get()
                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
                    'theme_id' => $copy->getKey(),
                    'path' => $file->path,
                    'storage_key' => $file->storage_key,
                    'sha256' => $file->sha256,
                    'byte_size' => $file->byte_size,
                ]));

            ThemeSettings::withoutGlobalScopes()->create([
                'theme_id' => $copy->getKey(),
                'settings_json' => $theme->settings?->settings_json ?? [],
                'updated_at' => now(),
            ]);
        });

        session()->flash('status', 'Theme duplicated');
    }

    public function deleteTheme(int $themeId): void
    {
        $theme = $this->theme($themeId);

        $this->authorize('delete', $theme);

        if ($theme->isPublished()) {
            $this->addError('theme', __('Published themes cannot be deleted.'));

            return;
        }

        $theme->delete();

        session()->flash('status', 'Theme deleted');
    }

    /**
     * @return Collection<int, Theme>
     */
    public function themes(): Collection
    {
        return Theme::withoutGlobalScopes()
            ->withCount('files')
            ->where('store_id', $this->storeId)
            ->orderByRaw("case when status = 'published' then 0 else 1 end")
            ->orderBy('name')
            ->get();
    }

    public function statusColor(Theme $theme): string
    {
        return $theme->status === ThemeStatus::Published ? 'green' : 'zinc';
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.index', [
            'themes' => $this->themes(),
        ])->layout('layouts.app', [
            'title' => __('Themes'),
        ]);
    }

    private function store(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function theme(int $themeId): Theme
    {
        return Theme::withoutGlobalScopes()
            ->with(['files', 'settings'])
            ->where('store_id', $this->storeId)


sed -n '150,260p' app/Livewire/Admin/Themes/Index.php


sed -n '150,260p' app/Livewire/Admin/Themes/Index.php

            ->where('store_id', $this->storeId)
            ->whereKey($themeId)
            ->firstOrFail();
    }
}


Output:
Chunk ID: d826a7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 30
Output:
            ->where('store_id', $this->storeId)
            ->whereKey($themeId)
            ->firstOrFail();
    }
}


For the theme slice I’m adding actual file contents behind `theme_files.storage_key` on the local disk, a file selector/editor in the existing theme editor, and duplication that copies file content instead of pointing both themes at the same storage key.


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Themes/Editor.php
@@
 use App\Enums\ThemeStatus;
 use App\Models\Store;
 use App\Models\Theme;
+use App\Models\ThemeFile;
 use App\Models\ThemeSettings;
 use App\Services\ThemeSettingsService;
 use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 use Livewire\Component;
@@
     public string $selectedSection = 'announcement';

+    public ?int $selectedFileId = null;
+
+    public string $fileContents = '';
+
@@
     public function selectSection(string $sectionKey): void
     {
         abort_unless(array_key_exists($sectionKey, $this->sections()), 404);

         $this->selectedSection = $sectionKey;
+        $this->selectedFileId = null;
+        $this->fileContents = '';
+    }
+
+    public function selectFile(int $fileId): void
+    {
+        $file = $this->themeFile($fileId);
+
+        $this->authorize('update', $this->theme);
+
+        $this->selectedFileId = $file->getKey();
+        $this->fileContents = Storage::disk('local')->exists($file->storage_key)
+            ? Storage::disk('local')->get($file->storage_key)
+            : '';
+    }
+
+    public function saveFile(): void
+    {
+        abort_unless($this->selectedFileId !== null, 404);
+
+        $this->authorize('update', $this->theme);
+
+        $this->validate([
+            'fileContents' => ['string', 'max:262144'],
+        ]);
+
+        $file = $this->themeFile($this->selectedFileId);
+        Storage::disk('local')->put($file->storage_key, $this->fileContents);
+
+        $file->forceFill([
+            'sha256' => hash('sha256', $this->fileContents),
+            'byte_size' => strlen($this->fileContents),
+        ])->save();
+
+        session()->flash('status', 'Theme file saved');
+        $this->dispatch('toast', type: 'success', message: __('Theme file saved'));
     }
@@
             'sections' => $this->sections(),
             'activeSection' => $this->sections()[$this->selectedSection],
+            'themeFiles' => $this->themeFiles(),
+            'selectedFile' => $this->selectedFile(),
             'previewUrl' => $this->previewUrl(),
         ])->layout('layouts.app', [
             'title' => __('Theme editor'),
         ]);
@@
     private function store(): Store
     {
         return Store::query()->whereKey($this->theme->store_id)->firstOrFail();
     }
+
+    /**
+     * @return \Illuminate\Database\Eloquent\Collection<int, ThemeFile>
+     */
+    private function themeFiles(): \Illuminate\Database\Eloquent\Collection
+    {
+        return ThemeFile::withoutGlobalScopes()
+            ->where('theme_id', $this->theme->getKey())
+            ->orderBy('path')
+            ->get();
+    }
+
+    private function selectedFile(): ?ThemeFile
+    {
+        if ($this->selectedFileId === null) {
+            return null;
+        }
+
+        return $this->themeFile($this->selectedFileId);
+    }
+
+    private function themeFile(int $fileId): ThemeFile
+    {
+        return ThemeFile::withoutGlobalScopes()
+            ->where('theme_id', $this->theme->getKey())
+            ->whereKey($fileId)
+            ->firstOrFail();
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Themes/Editor.php

@@ -7,2 +7,3 @@
 use App\Models\Theme;
+use App\Models\ThemeFile;
 use App\Models\ThemeSettings;
@@ -11,2 +12,3 @@
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 use Livewire\Component;
@@ -21,2 +23,6 @@

+    public ?int $selectedFileId = null;
+
+    public string $fileContents = '';
+
     /**
@@ -47,2 +53,38 @@
         $this->selectedSection = $sectionKey;
+        $this->selectedFileId = null;
+        $this->fileContents = '';
+    }
+
+    public function selectFile(int $fileId): void
+    {
+        $file = $this->themeFile($fileId);
+
+        $this->authorize('update', $this->theme);
+
+        $this->selectedFileId = $file->getKey();
+        $this->fileContents = Storage::disk('local')->exists($file->storage_key)
+            ? Storage::disk('local')->get($file->storage_key)
+            : '';
+    }
+
+    public function saveFile(): void
+    {
+        abort_unless($this->selectedFileId !== null, 404);
+
+        $this->authorize('update', $this->theme);
+
+        $this->validate([
+            'fileContents' => ['string', 'max:262144'],
+        ]);
+
+        $file = $this->themeFile($this->selectedFileId);
+        Storage::disk('local')->put($file->storage_key, $this->fileContents);
+
+        $file->forceFill([
+            'sha256' => hash('sha256', $this->fileContents),
+            'byte_size' => strlen($this->fileContents),
+        ])->save();
+
+        session()->flash('status', 'Theme file saved');
+        $this->dispatch('toast', type: 'success', message: __('Theme file saved'));
     }
@@ -148,2 +190,4 @@
             'activeSection' => $this->sections()[$this->selectedSection],
+            'themeFiles' => $this->themeFiles(),
+            'selectedFile' => $this->selectedFile(),
             'previewUrl' => $this->previewUrl(),
@@ -158,2 +202,30 @@
     }
+
+    /**
+     * @return \Illuminate\Database\Eloquent\Collection<int, ThemeFile>
+     */
+    private function themeFiles(): \Illuminate\Database\Eloquent\Collection
+    {
+        return ThemeFile::withoutGlobalScopes()
+            ->where('theme_id', $this->theme->getKey())
+            ->orderBy('path')
+            ->get();
+    }
+
+    private function selectedFile(): ?ThemeFile
+    {
+        if ($this->selectedFileId === null) {
+            return null;
+        }
+
+        return $this->themeFile($this->selectedFileId);
+    }
+
+    private function themeFile(int $fileId): ThemeFile
+    {
+        return ThemeFile::withoutGlobalScopes()
+            ->where('theme_id', $this->theme->getKey())
+            ->whereKey($fileId)
+            ->firstOrFail();
+    }
 }


Output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Themes/Editor.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: resources/views/livewire/admin/themes/editor.blade.php
@@

             <flux:heading size="lg">Sections</flux:heading>

                 @foreach ($sections as $key => $section)
@@
                         {{ $section['label'] }}
                     </button>
                 @endforeach

+
+            <flux:separator class="my-4" />
+
+            <flux:heading size="lg">Files</flux:heading>
+
+

+                @foreach ($themeFiles as $file)
+                    <button
+                        type="button"
+                        wire:click="selectFile({{ $file->getKey() }})"
+                        class="block w-full rounded-lg px-3 py-2 text-left text-sm {{ $selectedFileId === $file->getKey() ? 'bg-blue-50 font-medium text-blue-950 dark:bg-blue-950/40 dark:text-blue-100' : 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800' }}"
+                    >
+                        {{ $file->path }}
+                    </button>
+                @endforeach
+

@@
-        <form wire:submit="save" class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
-            <flux:heading size="lg">{{ $activeSection['label'] }}</flux:heading>
+        <form wire:submit="{{ $selectedFileId ? 'saveFile' : 'save' }}" class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
+

+                <flux:heading size="lg">{{ $selectedFile ? $selectedFile->path : $activeSection['label'] }}</flux:heading>
+
+                @if ($selectedFile)
+                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>
+                @endif
+

-                @foreach ($activeSection['fields'] as $field)
-                    @php($model = 'settings.'.str_replace('.', '.', $field['key']))
-
-                    @if ($field['type'] === 'checkbox')
-                        <flux:checkbox wire:model="{{ $model }}" label="{{ $field['label'] }}" />
-                    @elseif ($field['type'] === 'textarea')
-                        <flux:textarea wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" rows="3" />
-                    @elseif ($field['type'] === 'number')
-                        <flux:input wire:model.live.debounce.500ms="{{ $model }}" type="number" min="0" label="{{ $field['label'] }}" />
-                    @else
-                        <flux:input wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" />
-                    @endif
-                @endforeach
+                @if ($selectedFile)
+                    <flux:textarea wire:model="fileContents" rows="24" class="font-mono text-xs" />
+                    <flux:error name="fileContents" />
+                @else
+                    @foreach ($activeSection['fields'] as $field)
+                        @php($model = 'settings.'.str_replace('.', '.', $field['key']))
+
+                        @if ($field['type'] === 'checkbox')
+                            <flux:checkbox wire:model="{{ $model }}" label="{{ $field['label'] }}" />
+                        @elseif ($field['type'] === 'textarea')
+                            <flux:textarea wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" rows="3" />
+                        @elseif ($field['type'] === 'number')
+                            <flux:input wire:model.live.debounce.500ms="{{ $model }}" type="number" min="0" label="{{ $field['label'] }}" />
+                        @else
+                            <flux:input wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" />
+                        @endif
+                    @endforeach
+                @endif
+

+
+

+                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
+                    {{ $selectedFile ? 'Save file' : 'Save settings' }}
+                    Saving...
+                </flux:button>

         </form>

*** End Patch


Success. Updated the following files:
M resources/views/livewire/admin/themes/editor.blade.php

@@ -29,2 +29,18 @@

+
+            <flux:separator class="my-4" />
+
+            <flux:heading size="lg">Files</flux:heading>
+
+

+                @foreach ($themeFiles as $file)
+                    <button
+                        type="button"
+                        wire:click="selectFile({{ $file->getKey() }})"
+                        class="block w-full rounded-lg px-3 py-2 text-left text-sm {{ $selectedFileId === $file->getKey() ? 'bg-blue-50 font-medium text-blue-950 dark:bg-blue-950/40 dark:text-blue-100' : 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800' }}"
+                    >
+                        {{ $file->path }}
+                    </button>
+                @endforeach
+

@@ -49,19 +65,37 @@

-        <form wire:submit="save" class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
-            <flux:heading size="lg">{{ $activeSection['label'] }}</flux:heading>
+        <form wire:submit="{{ $selectedFileId ? 'saveFile' : 'save' }}" class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
+

+                <flux:heading size="lg">{{ $selectedFile ? $selectedFile->path : $activeSection['label'] }}</flux:heading>

+                @if ($selectedFile)
+                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>
+                @endif
+

+

-                @foreach ($activeSection['fields'] as $field)
-                    @php($model = 'settings.'.str_replace('.', '.', $field['key']))
+                @if ($selectedFile)
+                    <flux:textarea wire:model="fileContents" rows="24" class="font-mono text-xs" />
+                    <flux:error name="fileContents" />
+                @else
+                    @foreach ($activeSection['fields'] as $field)
+                        @php($model = 'settings.'.str_replace('.', '.', $field['key']))

-                    @if ($field['type'] === 'checkbox')
-                        <flux:checkbox wire:model="{{ $model }}" label="{{ $field['label'] }}" />
-                    @elseif ($field['type'] === 'textarea')
-                        <flux:textarea wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" rows="3" />
-                    @elseif ($field['type'] === 'number')
-                        <flux:input wire:model.live.debounce.500ms="{{ $model }}" type="number" min="0" label="{{ $field['label'] }}" />
-                    @else
-                        <flux:input wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" />
-                    @endif
-                @endforeach
+                        @if ($field['type'] === 'checkbox')
+                            <flux:checkbox wire:model="{{ $model }}" label="{{ $field['label'] }}" />
+                        @elseif ($field['type'] === 'textarea')
+                            <flux:textarea wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" rows="3" />
+                        @elseif ($field['type'] === 'number')
+                            <flux:input wire:model.live.debounce.500ms="{{ $model }}" type="number" min="0" label="{{ $field['label'] }}" />
+                        @else
+                            <flux:input wire:model.live.debounce.500ms="{{ $model }}" label="{{ $field['label'] }}" />
+                        @endif
+                    @endforeach
+                @endif
+

+
+

+                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
+                    {{ $selectedFile ? 'Save file' : 'Save settings' }}
+                    Saving...
+                </flux:button>


Output: {"output":"Success. Updated the following files:\nM resources/views/livewire/admin/themes/editor.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


/bin/zsh
-lc
rg -n "Number::" resources app

Question: Number::

resources/views/livewire/admin/themes/editor.blade.php:71:                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>


Output:
Chunk ID: 6d6584
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 38
Output:
resources/views/livewire/admin/themes/editor.blade.php:71:                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>


Input:
*** Begin Patch
*** Update File: resources/views/livewire/admin/themes/editor.blade.php
@@
-                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>
+                    <flux:badge>{{ \Illuminate\Support\Number::fileSize($selectedFile->byte_size) }}</flux:badge>
*** End Patch


@@ -70,3 +70,3 @@
                 @if ($selectedFile)
-                    <flux:badge>{{ Number::fileSize($selectedFile->byte_size) }}</flux:badge>
+                    <flux:badge>{{ \Illuminate\Support\Number::fileSize($selectedFile->byte_size) }}</flux:badge>
                 @endif


Output: {"output":"Success. Updated the following files:\nM resources/views/livewire/admin/themes/editor.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: database/seeders/ThemeFileSeeder.php
@@
 use App\Models\Theme;
 use App\Models\ThemeFile;
 use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\Storage;
@@
             foreach ([
                 'layouts/storefront.blade.php',
                 'sections/hero.blade.php',
                 'sections/featured-products.blade.php',
             ] as $path) {
+                $contents = $this->contents($theme, $path);
+                $storageKey = "themes/{$theme->getKey()}/{$path}";
+
+                Storage::disk('local')->put($storageKey, $contents);
+
                 ThemeFile::withoutGlobalScopes()->updateOrCreate(
                     [
                         'theme_id' => $theme->getKey(),
                         'path' => $path,
                     ],
                     [
-                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
-                        'sha256' => hash('sha256', "{$theme->getKey()}:{$path}"),
-                        'byte_size' => 1024,
+                        'storage_key' => $storageKey,
+                        'sha256' => hash('sha256', $contents),
+                        'byte_size' => strlen($contents),
                     ],
                 );
             }
         });
     }
+
+    private function contents(Theme $theme, string $path): string
+    {
+        return match ($path) {
+            'layouts/storefront.blade.php' => "<x-layouts.storefront :title=\"\$title ?? '{$theme->name}'\">\n    {{ \$slot }}\n</x-layouts.storefront>\n",
+            'sections/hero.blade.php' => "
\n
{{ data_get(\$settings, 'home.hero.heading') }}
\n
\n",
+            'sections/featured-products.blade.php' => "
\n    @foreach(\$products as \$product)\n
{{ \$product->title }}
\n    @endforeach\n
\n",
+            default => '',
+        };
+    }
 }
*** End Patch


Success. Updated the following files:
M database/seeders/ThemeFileSeeder.php

@@ -7,2 +7,3 @@
 use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\Storage;

@@ -21,2 +22,7 @@
             ] as $path) {
+                $contents = $this->contents($theme, $path);
+                $storageKey = "themes/{$theme->getKey()}/{$path}";
+
+                Storage::disk('local')->put($storageKey, $contents);
+
                 ThemeFile::withoutGlobalScopes()->updateOrCreate(
@@ -27,5 +33,5 @@
                     [
-                        'storage_key' => "themes/{$theme->getKey()}/{$path}",
-                        'sha256' => hash('sha256', "{$theme->getKey()}:{$path}"),
-                        'byte_size' => 1024,
+                        'storage_key' => $storageKey,
+                        'sha256' => hash('sha256', $contents),
+                        'byte_size' => strlen($contents),
                     ],
@@ -35,2 +41,12 @@
     }
+
+    private function contents(Theme $theme, string $path): string
+    {
+        return match ($path) {
+            'layouts/storefront.blade.php' => "<x-layouts.storefront :title=\"\$title ?? '{$theme->name}'\">\n    {{ \$slot }}\n</x-layouts.storefront>\n",
+            'sections/hero.blade.php' => "
\n
{{ data_get(\$settings, 'home.hero.heading') }}
\n
\n",
+            'sections/featured-products.blade.php' => "
\n    @foreach(\$products as \$product)\n
{{ \$product->title }}
\n    @endforeach\n
\n",
+            default => '',
+        };
+    }
 }


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


Input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Themes/Index.php
@@
 use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
@@
-            $theme->files()
+            $theme->files()
                 ->withoutGlobalScopes()
                 ->get()
-                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
-                    'theme_id' => $copy->getKey(),
-                    'path' => $file->path,
-                    'storage_key' => $file->storage_key,
-                    'sha256' => $file->sha256,
-                    'byte_size' => $file->byte_size,
-                ]));
+                ->each(function (ThemeFile $file) use ($copy): void {
+                    $contents = Storage::disk('local')->exists($file->storage_key)
+                        ? Storage::disk('local')->get($file->storage_key)
+                        : '';
+                    $storageKey = "themes/{$copy->getKey()}/{$file->path}";
+
+                    Storage::disk('local')->put($storageKey, $contents);
+
+                    ThemeFile::withoutGlobalScopes()->create([
+                        'theme_id' => $copy->getKey(),
+                        'path' => $file->path,
+                        'storage_key' => $storageKey,
+                        'sha256' => hash('sha256', $contents),
+                        'byte_size' => strlen($contents),
+                    ]);
+                });
*** End Patch


Success. Updated the following files:
M app/Livewire/Admin/Themes/Index.php

@@ -13,2 +13,3 @@
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 use Livewire\Attributes\Locked;
@@ -78,9 +79,18 @@
                 ->get()
-                ->each(fn (ThemeFile $file): ThemeFile => ThemeFile::withoutGlobalScopes()->create([
-                    'theme_id' => $copy->getKey(),
-                    'path' => $file->path,
-                    'storage_key' => $file->storage_key,
-                    'sha256' => $file->sha256,
-                    'byte_size' => $file->byte_size,
-                ]));
+                ->each(function (ThemeFile $file) use ($copy): void {
+                    $contents = Storage::disk('local')->exists($file->storage_key)
+                        ? Storage::disk('local')->get($file->storage_key)
+                        : '';
+                    $storageKey = "themes/{$copy->getKey()}/{$file->path}";
+
+                    Storage::disk('local')->put($storageKey, $contents);
+
+                    ThemeFile::withoutGlobalScopes()->create([
+                        'theme_id' => $copy->getKey(),
+                        'path' => $file->path,
+                        'storage_key' => $storageKey,
+                        'sha256' => hash('sha256', $contents),
+                        'byte_size' => strlen($contents),
+                    ]);
+                });


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


Input:
*** Begin Patch
*** Update File: tests/Feature/Admin/ContentManagementTest.php
@@
 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;
@@
 test('themes can be duplicated edited and published', function (): void {
@@
     $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])
@@
         ->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 {
*** End Patch


Success. Updated the following files:
M tests/Feature/Admin/ContentManagementTest.php

@@ -20,2 +20,3 @@
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
 use Livewire\Livewire;
@@ -184,2 +185,7 @@
         ->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));

@@ -198,2 +204,25 @@

+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 {


Output: {"output":"Success. Updated the following files:\nM tests/Feature/Admin/ContentManagementTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The theme editor now has file selection and save behavior, and theme duplication copies file storage instead of sharing keys. I’m running Pint and the focused content-management tests.


vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Admin/ContentManagementTest.php


Output:
Chunk ID: e674f6
Wall time: 1.0009 seconds
Process running with session ID 36684
Original token count: 5
Output:
{"result":"pass"}


vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Admin/ContentManagementTest.php

{"result":"pass"}
  ......

  Tests:    6 passed (47 assertions)
  Duration: 2.84s


Output:
Chunk ID: 711fa5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 17
Output:

  ......

  Tests:    6 passed (47 assertions)
  Duration: 2.84s


Focused content tests pass: 6 tests, 47 assertions. I’m broadening to the admin suite, build, and full tests because theme editor changes touch shared admin UI and seeders.


php artisan test --compact tests/Feature/Admin


> build
> vite build

vite v7.3.1 building client environment for production...
transforming...
✓ 2 modules transformed.
Generated an empty chunk: "app".
rendering chunks...
computing gzip size...
public/build/manifest.json              0.33 kB │ gzip:  0.17 kB
public/build/assets/app-CaLPAHkV.css  228.43 kB │ gzip: 31.35 kB
public/build/assets/app-l0sNRNKZ.js     0.00 kB │ gzip:  0.02 kB
✓ built in 413ms


Output:
Chunk ID: 84b2fc
Wall time: 1.0013 seconds
Process running with session ID 33228
Original token count: 0
Output:


Output:
Chunk ID: 34c351
Wall time: 0.7765 seconds
Process exited with code 0
Original token count: 105
Output:

> build
> vite build

vite v7.3.1 building client environment for production...
transforming...
✓ 2 modules transformed.
Generated an empty chunk: "app".
rendering chunks...
computing gzip size...
public/build/manifest.json              0.33 kB │ gzip:  0.17 kB
public/build/assets/app-CaLPAHkV.css  228.43 kB │ gzip: 31.35 kB
public/build/assets/app-l0sNRNKZ.js     0.00 kB │ gzip:  0.02 kB
✓ built in 413ms


Output:
Chunk ID: 53cb13
Wall time: 5.0016 seconds
Process running with session ID 33228
Original token count: 10
Output:

  ...................................


Output:
Chunk ID: c78d5b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 18
Output:
.........

  Tests:    44 passed (280 assertions)
  Duration: 17.66s


The admin suite is green: 44 tests, 280 assertions, and the asset build passed. I’m running the full Pest suite now.


Output:
Chunk ID: efcc63
Wall time: 1.0017 seconds
Process running with session ID 92046
Original token count: 1
Output:

  .


Output:
Chunk ID: 6a8a87
Wall time: 5.0009 seconds
Process running with session ID 92046
Original token count: 7
Output:
..........................


Output:
Chunk ID: bf9d17
Wall time: 5.0018 seconds
Process running with session ID 92046
Original token count: 8
Output:
...............................


Output:
Chunk ID: 052296
Wall time: 5.0008 seconds
Process running with session ID 92046
Original token count: 28
Output:
..................
  ............................................................................
  ...........


Output:
Chunk ID: bbadeb
Wall time: 1.6254 seconds
Process exited with code 0
Original token count: 25
Output:
.....................................

  Tests:    200 passed (1019 assertions)
  Duration: 40.04s


The full suite is green after theme file editing: 200 tests, 1019 assertions. I’m reseeding the local app DB and checking the theme editor in the browser.


php artisan migrate:fresh --seed --no-interaction


Output:
Chunk ID: 323955
Wall time: 1.0018 seconds
Process running with session ID 81845
Original token count: 1948
Output:

  Dropping all tables ............................................ 4.64ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.89ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.51ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.62ms DONE
  0001_01_01_000002_create_jobs_table ............................ 1.01ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.46ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.23ms DONE
  2026_05_03_211413_create_stores_table .......................... 4.32ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.41ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.16ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.34ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.31ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.19ms DONE
  2026_05_03_213856_create_products_table ........................ 0.67ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.34ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.34ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.65ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.24ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.32ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.56ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.33ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.45ms DONE
  2026_05_03_223824_create_themes_table .......................... 0.32ms DONE
  2026_05_03_223830_create_theme_files_table ..................... 0.30ms DONE
  2026_05_03_223837_create_theme_settings_table .................. 0.15ms DONE
  2026_05_03_223841_create_pages_table ........................... 0.38ms DONE
  2026_05_03_223844_create_navigation_menus_table ................ 0.29ms DONE
  2026_05_03_223848_create_navigation_items_table ................ 0.58ms DONE
  2026_05_03_225400_create_carts_table ........................... 0.56ms DONE
  2026_05_03_225405_create_cart_lines_table ...................... 0.41ms DONE
  2026_05_03_225409_create_checkouts_table ....................... 0.79ms DONE
  2026_05_03_225413_create_shipping_zones_table .................. 0.32ms DONE
  2026_05_03_225418_create_shipping_rates_table .................. 0.34ms DONE
  2026_05_03_225422_create_tax_settings_table .................... 0.20ms DONE
  2026_05_03_225427_create_discounts_table ....................... 0.54ms DONE
  2026_05_04_000408_create_customer_addresses_table .............. 0.32ms DONE
  2026_05_04_000408_create_orders_table .......................... 1.08ms DONE
  2026_05_04_000410_create_order_lines_table ..................... 0.55ms DONE
  2026_05_04_000411_create_payments_table ........................ 0.52ms DONE
  2026_05_04_000412_create_refunds_table ......................... 0.41ms DONE
  2026_05_04_000413_create_fulfillments_table .................... 0.41ms DONE
  2026_05_04_000414_create_fulfillment_lines_table ............... 0.32ms DONE
  2026_05_04_030847_create_search_settings_table ................. 0.19ms DONE
  2026_05_04_030848_create_products_fts_table .................... 0.32ms DONE
  2026_05_04_030848_create_search_queries_table .................. 0.36ms DONE
  2026_05_04_032640_create_analytics_events_table ................ 0.55ms DONE
  2026_05_04_032641_create_analytics_dailies_table ............... 0.32ms DONE
  2026_05_04_034556_create_apps_table ............................ 0.20ms DONE
  2026_05_04_034601_create_app_installations_table ............... 0.56ms DONE
  2026_05_04_034606_create_oauth_clients_table ................... 0.35ms DONE
  2026_05_04_034611_create_oauth_tokens_table .................... 0.43ms DONE
  2026_05_04_034616_create_webhook_subscriptions_table ........... 0.48ms DONE
  2026_05_04_034621_create_webhook_deliveries_table .............. 0.48ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 6 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\AppSeeder ......................................... RUNNING
  Database\Seeders\AppSeeder ....................................... 8 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 269 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 1 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\SearchSettingsSeeder .............................. RUNNING
  Database\Seeders\SearchSettingsSeeder ............................ 1 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 5 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder ................................. 203 ms DONE

  Database\Seeders\ThemeSeeder ....................................... RUNNING
  Database\Seeders\ThemeSeeder ..................................... 1 ms DONE

  Database\Seeders\ThemeFileSeeder ................................... RUNNING
  Database\Seeders\ThemeFileSeeder ................................. 9 ms DONE

  Database\Seeders\ThemeSettingsSeeder ............................... RUNNING
  Database\Seeders\ThemeSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\PageSeeder ........................................ RUNNING
  Database\Seeders\PageSeeder ...................................... 2 ms DONE

  Database\Seeders\NavigationMenuSeeder .............................. RUNNING
  Database\Seeders\NavigationMenuSeeder ............................ 3 ms DONE

  Database\Seeders\NavigationItemSeeder .............................. RUNNING
  Database\Seeders\NavigationItemSeeder ............................ 4 ms DONE

  Database\Seeders\TaxSettingsSeeder ................................. RUNNING
  Database\Seeders\TaxSettingsSeeder ............................... 1 ms DONE

  Database\Seeders\ShippingZoneSeeder ................................ RUNNING
  Database\Seeders\ShippingZoneSeeder .............................. 1 ms DONE

  Database\Seeders\ShippingRateSeeder ................................ RUNNING
  Database\Seeders\ShippingRateSeeder .............................. 1 ms DONE

  Database\Seeders\DiscountSeeder .................................... RUNNING
  Database\Seeders\DiscountSeeder .................................. 3 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 266 ms DONE

  Database\Seeders\AnalyticsSeeder ................................... RUNNING