Output:
Chunk ID: c97bdb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2249
Output:
---
name: laravel-best-practices
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
license: MIT
metadata:
  author: laravel
---

# Laravel Best Practices

Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.

## Consistency First

Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.

Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.

## Quick Reference

### 1. Database Performance → `rules/db-performance.md`

- Eager load with `with()` to prevent N+1 queries
- Enable `Model::preventLazyLoading()` in development
- Select only needed columns, avoid `SELECT *`
- `chunk()` / `chunkById()` for large datasets
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
- `withCount()` instead of loading relations to count
- `cursor()` for memory-efficient read-only iteration
- Never query in Blade templates

### 2. Advanced Query Patterns → `rules/advanced-queries.md`

- `addSelect()` subqueries over eager-loading entire has-many for a single value
- Dynamic relationships via subquery FK + `belongsTo`
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
- `setRelation()` to prevent circular N+1 queries
- `whereIn` + `pluck()` over `whereHas` for better index usage
- Two simple queries can beat one complex query
- Compound indexes matching `orderBy` column order
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)

### 3. Security → `rules/security.md`

- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
- No raw SQL with user input — use Eloquent or query builder
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
- Validate MIME type, extension, and size for file uploads
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields

### 4. Caching → `rules/caching.md`

- `Cache::remember()` over manual get/put
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
- `Cache::memo()` to avoid redundant cache hits within a request
- Cache tags to invalidate related groups
- `Cache::add()` for atomic conditional writes
- `once()` to memoize per-request or per-object lifetime
- `Cache::lock()` / `lockForUpdate()` for race conditions
- Failover cache stores in production

### 5. Eloquent Patterns → `rules/eloquent.md`

- Correct relationship types with return type hints
- Local scopes for reusable query constraints
- Global scopes sparingly — document their existence
- Attribute casts in the `casts()` method
- Cast date columns, use Carbon instances in templates
- `whereBelongsTo($model)` for cleaner queries
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries

### 6. Validation & Forms → `rules/validation.md`

- Form Request classes, not inline validation
- Array notation `['required', 'email']` for new code; follow existing convention
- `$request->validated()` only — never `$request->all()`
- `Rule::when()` for conditional validation
- `after()` instead of `withValidator()`

### 7. Configuration → `rules/config.md`

- `env()` only inside config files
- `App::environment()` or `app()->isProduction()`
- Config, lang files, and constants over hardcoded text

### 8. Testing Patterns → `rules/testing.md`

- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
- `assertModelExists()` over raw `assertDatabaseHas()`
- Factory states and sequences over manual overrides
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
- `recycle()` to share relationship instances across factories

### 9. Queue & Job Patterns → `rules/queue-jobs.md`

- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios

### 10. Routing & Controllers → `rules/routing.md`

- Implicit route model binding
- Scoped bindings for nested resources
- `Route::resource()` or `apiResource()`
- Methods under 10 lines — extract to actions/services
- Type-hint Form Requests for auto-validation

### 11. HTTP Client → `rules/http-client.md`

- Explicit `timeout` and `connectTimeout` on every request
- `retry()` with exponential backoff for external APIs
- Check response status or use `throw()`
- `Http::pool()` for concurrent independent requests
- `Http::fake()` and `preventStrayRequests()` in tests

### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`

- Event discovery over manual registration; `event:cache` in production
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
- Queue notifications and mailables with `ShouldQueue`
- On-demand notifications for non-user recipients
- `HasLocalePreference` on notifiable models
- `assertQueued()` not `assertSent()` for queued mailables
- Markdown mailables for transactional emails

### 13. Error Handling → `rules/error-handling.md`

- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
- `ShouldntReport` for exceptions that should never log
- Throttle high-volume exceptions to protect log sinks
- `dontReportDuplicates()` for multi-catch scenarios
- Force JSON rendering for API routes
- Structured context via `context()` on exception classes

### 14. Task Scheduling → `rules/scheduling.md`

- `withoutOverlapping()` on variable-duration tasks
- `onOneServer()` on multi-server deployments
- `runInBackground()` for concurrent long tasks
- `environments()` to restrict to appropriate environments
- `takeUntilTimeout()` for time-bounded processing
- Schedule groups for shared configuration

### 15. Architecture → `rules/architecture.md`

- Single-purpose Action classes; dependency injection over `app()` helper
- Prefer official Laravel packages and follow conventions, don't override defaults
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution

### 16. Migrations → `rules/migrations.md`

- Generate migrations with `php artisan make:migration`
- `constrained()` for foreign keys
- Never modify migrations that have run in production
- Add indexes in the migration, not as an afterthought
- Mirror column defaults in model `$attributes`
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
- One concern per migration — never mix DDL and DML

### 17. Collections → `rules/collections.md`

- Higher-order messages for simple collection operations
- `cursor()` vs. `lazy()` — choose based on relationship needs
- `lazyById()` when updating records while iterating
- `toQuery()` for bulk operations on collections

### 18. Blade & Views → `rules/blade-views.md`

- `$attributes->merge()` in component templates
- Blade components over `@include`; `@pushOnce` for per-component scripts
- View Composers for shared view data
- `@aware` for deeply nested component props

### 19. Conventions & Style → `rules/style.md`

- Follow Laravel naming conventions for all entities
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
- No JS/CSS in Blade, no HTML in PHP classes
- Code should be readable; comments only for config files

## How to Apply

Always use a sub-agent to read rule files and explore this skill's content.

1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version


Output:
Chunk ID: af48c2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1275
Output:
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
  author: laravel
---

# Pest Testing 4

## Documentation

Use `search-docs` for detailed Pest 4 patterns and documentation.

## Basic Usage

### Creating Tests

All tests must be written using Pest. Use `php artisan make:test --pest {name}`.

### Test Organization

- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.

### Basic Test Structure

Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.

<!-- Basic Pest Test Example -->
```php
it('is true', function () {
    expect(true)->toBeTrue();
});
```

### Running Tests

- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.

## Assertions

Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:

<!-- Pest Response Assertion -->
```php
it('returns all', function () {
    $this->postJson('/api/docs', [])->assertSuccessful();
});
```

| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |

## Mocking

Import mock function before use: `use function Pest\Laravel\mock;`

## Datasets

Use datasets for repetitive tests (validation rules, etc.):

<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
    expect($email)->not->toBeEmpty();
})->with([
    'james' => 'james@laravel.com',
    'taylor' => 'taylor@laravel.com',
]);
```

## Pest 4 Features

| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |

### Browser Test Example

Browser tests run in real browsers for full integration testing:

- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.

<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
    Notification::fake();

    $this->actingAs(User::factory()->create());

    $page = visit('/sign-in');

    $page->assertSee('Sign In')
        ->assertNoJavaScriptErrors()
        ->click('Forgot Password?')
        ->fill('email', 'nuno@laravel.com')
        ->click('Send Reset Link')
        ->assertSee('We have emailed your password reset link!');

    Notification::assertSent(ResetPassword::class);
});
```

### Smoke Testing

Quickly validate multiple pages have no JavaScript errors:

<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);

$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```

### Visual Regression Testing

Capture and compare screenshots to detect visual changes.

### Test Sharding

Split tests across parallel processes for faster CI runs.

### Architecture Testing

Pest 4 includes architecture testing (from Pest 3):

<!-- Architecture Test Example -->
```php
arch('controllers')
    ->expect('App\Http\Controllers')
    ->toExtendNothing()
    ->toHaveSuffix('Controller');
```

## Common Pitfalls

- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests


The project reports Laravel 12.51, PHP 8.4, Pest 4.3, and the expected package set. I’m now reading the existing theme UI/service/test patterns so the API lands in the same shape instead of inventing a parallel theme workflow.


sed -n '1,220p' app/Enums/ThemeStatus.php


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


sed -n '1,280p' app/Livewire/Admin/Themes/Editor.php


sed -n '1,260p' app/Services/ThemeSettingsService.php


sed -n '1,220p' database/factories/ThemeFactory.php


sed -n '1,220p' database/factories/ThemeSettingsFactory.php


sed -n '1,220p' app/Enums/ThemeStatus.php

<?php

namespace App\Enums;

enum ThemeStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
}


Output:
Chunk ID: 03bac0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 30
Output:
<?php

namespace App\Enums;

enum ThemeStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
}


sed -n '1,260p' 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 Illuminate\Support\Facades\Storage;
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(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),
                    ]);
                });

            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)
            ->whereKey($themeId)
            ->firstOrFail();
    }
}


Output:
Chunk ID: 7a7d17
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1201
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 Illuminate\Support\Facades\Storage;
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(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),
                    ]);
                });

            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)
            ->whereKey($themeId)
            ->firstOrFail();
    }
}


sed -n '1,280p' app/Livewire/Admin/Themes/Editor.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\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;

class Editor extends Component
{
    use AuthorizesRequests;

    public Theme $theme;

    public string $selectedSection = 'announcement';

    public ?int $selectedFileId = null;

    public string $fileContents = '';

    /**
     * @var array<string, mixed>
     */
    public array $settings = [];

    public function mount(Theme $theme, ThemeSettingsService $themeSettings): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);
        abort_unless((int) $theme->store_id === $store->getKey(), 404);

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

        $this->theme = $theme;
        $this->settings = array_replace_recursive(
            $themeSettings->defaultsForStore($store),
            $theme->settings?->settings_json ?? [],
        );
    }

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

    public function save(ThemeSettingsService $settings): void
    {
        $this->authorize('update', $this->theme);

        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
            ['theme_id' => $this->theme->getKey()],
            [
                'settings_json' => $this->settings,
                'updated_at' => now(),
            ],
        );

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

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

    public function publish(ThemeSettingsService $settings): void
    {
        $this->authorize('publish', $this->theme);

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

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

        $this->save($settings);

        session()->flash('status', 'Theme saved and published');
    }

    public function refreshPreview(): void
    {
        $this->dispatch('theme-preview-refresh');
    }

    /**
     * @return array<string, array{label: string, fields: array<int, array{key: string, label: string, type: string, options?: array<string, string>}>}>
     */
    public function sections(): array
    {
        return [
            'announcement' => [
                'label' => 'Announcement',
                'fields' => [
                    ['key' => 'announcement.enabled', 'label' => 'Enabled', 'type' => 'checkbox'],
                    ['key' => 'announcement.text', 'label' => 'Text', 'type' => 'text'],
                    ['key' => 'announcement.url', 'label' => 'URL', 'type' => 'text'],
                ],
            ],
            'header' => [
                'label' => 'Header',
                'fields' => [
                    ['key' => 'header.sticky', 'label' => 'Sticky header', 'type' => 'checkbox'],
                    ['key' => 'header.main_menu', 'label' => 'Main menu handle', 'type' => 'text'],
                ],
            ],
            'home' => [
                'label' => 'Home hero',
                'fields' => [
                    ['key' => 'home.hero.eyebrow', 'label' => 'Eyebrow', 'type' => 'text'],
                    ['key' => 'home.hero.heading', 'label' => 'Heading', 'type' => 'text'],
                    ['key' => 'home.hero.subheading', 'label' => 'Subheading', 'type' => 'textarea'],
                    ['key' => 'home.hero.primary_label', 'label' => 'Primary button label', 'type' => 'text'],
                    ['key' => 'home.hero.primary_url', 'label' => 'Primary button URL', 'type' => 'text'],
                    ['key' => 'home.featured_product_limit', 'label' => 'Featured products', 'type' => 'number'],
                    ['key' => 'home.featured_collection_limit', 'label' => 'Featured collections', 'type' => 'number'],
                ],
            ],
            'footer' => [
                'label' => 'Footer',
                'fields' => [
                    ['key' => 'footer.menu', 'label' => 'Menu handle', 'type' => 'text'],
                    ['key' => 'footer.tagline', 'label' => 'Tagline', 'type' => 'textarea'],
                ],
            ],
        ];
    }

    public function previewUrl(): string
    {
        return route('home');
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.editor', [
            '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();
    }
}


Output:
Chunk ID: 2c298a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1865
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\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;

class Editor extends Component
{
    use AuthorizesRequests;

    public Theme $theme;

    public string $selectedSection = 'announcement';

    public ?int $selectedFileId = null;

    public string $fileContents = '';

    /**
     * @var array<string, mixed>
     */
    public array $settings = [];

    public function mount(Theme $theme, ThemeSettingsService $themeSettings): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);
        abort_unless((int) $theme->store_id === $store->getKey(), 404);

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

        $this->theme = $theme;
        $this->settings = array_replace_recursive(
            $themeSettings->defaultsForStore($store),
            $theme->settings?->settings_json ?? [],
        );
    }

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

    public function save(ThemeSettingsService $settings): void
    {
        $this->authorize('update', $this->theme);

        ThemeSettings::withoutGlobalScopes()->updateOrCreate(
            ['theme_id' => $this->theme->getKey()],
            [
                'settings_json' => $this->settings,
                'updated_at' => now(),
            ],
        );

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

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

    public function publish(ThemeSettingsService $settings): void
    {
        $this->authorize('publish', $this->theme);

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

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

        $this->save($settings);

        session()->flash('status', 'Theme saved and published');
    }

    public function refreshPreview(): void
    {
        $this->dispatch('theme-preview-refresh');
    }

    /**
     * @return array<string, array{label: string, fields: array<int, array{key: string, label: string, type: string, options?: array<string, string>}>}>
     */
    public function sections(): array
    {
        return [
            'announcement' => [
                'label' => 'Announcement',
                'fields' => [
                    ['key' => 'announcement.enabled', 'label' => 'Enabled', 'type' => 'checkbox'],
                    ['key' => 'announcement.text', 'label' => 'Text', 'type' => 'text'],
                    ['key' => 'announcement.url', 'label' => 'URL', 'type' => 'text'],
                ],
            ],
            'header' => [
                'label' => 'Header',
                'fields' => [
                    ['key' => 'header.sticky', 'label' => 'Sticky header', 'type' => 'checkbox'],
                    ['key' => 'header.main_menu', 'label' => 'Main menu handle', 'type' => 'text'],
                ],
            ],
            'home' => [
                'label' => 'Home hero',
                'fields' => [
                    ['key' => 'home.hero.eyebrow', 'label' => 'Eyebrow', 'type' => 'text'],
                    ['key' => 'home.hero.heading', 'label' => 'Heading', 'type' => 'text'],
                    ['key' => 'home.hero.subheading', 'label' => 'Subheading', 'type' => 'textarea'],
                    ['key' => 'home.hero.primary_label', 'label' => 'Primary button label', 'type' => 'text'],
                    ['key' => 'home.hero.primary_url', 'label' => 'Primary button URL', 'type' => 'text'],
                    ['key' => 'home.featured_product_limit', 'label' => 'Featured products', 'type' => 'number'],
                    ['key' => 'home.featured_collection_limit', 'label' => 'Featured collections', 'type' => 'number'],
                ],
            ],
            'footer' => [
                'label' => 'Footer',
                'fields' => [
                    ['key' => 'footer.menu', 'label' => 'Menu handle', 'type' => 'text'],
                    ['key' => 'footer.tagline', 'label' => 'Tagline', 'type' => 'textarea'],
                ],
            ],
        ];
    }

    public function previewUrl(): string
    {
        return route('home');
    }

    public function render(): mixed
    {
        return view('livewire.admin.themes.editor', [
            '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();
    }
}


sed -n '1,260p' app/Services/ThemeSettingsService.php

<?php

namespace App\Services;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use Illuminate\Support\Facades\Cache;

class ThemeSettingsService
{
    /**
     * @return array<string, mixed>
     */
    public function forStore(Store $store): array
    {
        return Cache::remember($this->cacheKey($store), now()->addMinutes(5), function () use ($store): array {
            $theme = $this->publishedTheme($store);

            if ($theme === null) {
                return $this->defaultsForStore($store);
            }

            $settings = $theme->settings?->settings_json ?? [];

            return array_replace_recursive($this->defaultsForStore($store), $settings);
        });
    }

    public function publishedTheme(Store $store): ?Theme
    {
        return Theme::withoutGlobalScopes()
            ->with('settings')
            ->where('store_id', $store->getKey())
            ->where('status', ThemeStatus::Published)
            ->whereNotNull('published_at')
            ->latest('published_at')
            ->first();
    }

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

    /**
     * @return array<string, mixed>
     */
    public function defaultsForStore(Store $store): array
    {
        return [
            'announcement' => [
                'enabled' => true,
                'text' => "Free shipping on orders over 75.00 {$store->default_currency}",
                'url' => null,
            ],
            'header' => [
                'sticky' => true,
                'main_menu' => 'main-menu',
            ],
            'footer' => [
                'menu' => 'footer-menu',
                'tagline' => 'A self-contained demo storefront with scoped catalog data and checkout-ready products.',
            ],
            'home' => [
                'hero' => [
                    'eyebrow' => 'New season essentials',
                    'heading' => $store->name,
                    'subheading' => 'Browse a scoped demo catalog with variants, inventory states, sale pricing, and digital products.',
                    'primary_label' => 'Shop new arrivals',
                    'primary_url' => '/collections/new-arrivals',
                    'secondary_label' => 'View collections',
                    'secondary_url' => '/collections',
                ],
                'featured_product_limit' => 8,
                'featured_collection_limit' => 4,
            ],
        ];
    }

    private function cacheKey(Store $store): string
    {
        return "theme_settings:{$store->getKey()}";
    }
}


Output:
Chunk ID: 458289
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 661
Output:
<?php

namespace App\Services;

use App\Enums\ThemeStatus;
use App\Models\Store;
use App\Models\Theme;
use Illuminate\Support\Facades\Cache;

class ThemeSettingsService
{
    /**
     * @return array<string, mixed>
     */
    public function forStore(Store $store): array
    {
        return Cache::remember($this->cacheKey($store), now()->addMinutes(5), function () use ($store): array {
            $theme = $this->publishedTheme($store);

            if ($theme === null) {
                return $this->defaultsForStore($store);
            }

            $settings = $theme->settings?->settings_json ?? [];

            return array_replace_recursive($this->defaultsForStore($store), $settings);
        });
    }

    public function publishedTheme(Store $store): ?Theme
    {
        return Theme::withoutGlobalScopes()
            ->with('settings')
            ->where('store_id', $store->getKey())
            ->where('status', ThemeStatus::Published)
            ->whereNotNull('published_at')
            ->latest('published_at')
            ->first();
    }

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

    /**
     * @return array<string, mixed>
     */
    public function defaultsForStore(Store $store): array
    {
        return [
            'announcement' => [
                'enabled' => true,
                'text' => "Free shipping on orders over 75.00 {$store->default_currency}",
                'url' => null,
            ],
            'header' => [
                'sticky' => true,
                'main_menu' => 'main-menu',
            ],
            'footer' => [
                'menu' => 'footer-menu',
                'tagline' => 'A self-contained demo storefront with scoped catalog data and checkout-ready products.',
            ],
            'home' => [
                'hero' => [
                    'eyebrow' => 'New season essentials',
                    'heading' => $store->name,
                    'subheading' => 'Browse a scoped demo catalog with variants, inventory states, sale pricing, and digital products.',
                    'primary_label' => 'Shop new arrivals',
                    'primary_url' => '/collections/new-arrivals',
                    'secondary_label' => 'View collections',
                    'secondary_url' => '/collections',
                ],
                'featured_product_limit' => 8,
                'featured_collection_limit' => 4,
            ],
        ];
    }

    private function cacheKey(Store $store): string
    {
        return "theme_settings:{$store->getKey()}";
    }
}


sed -n '1,220p' database/factories/ThemeFactory.php

<?php

namespace Database\Factories;

use App\Enums\ThemeStatus;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Theme>
 */
class ThemeFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => fake()->words(2, true).' theme',
            'version' => '1.0.0',
            'status' => ThemeStatus::Draft,
            'published_at' => null,
        ];
    }

    public function published(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ThemeStatus::Published,
            'published_at' => now(),
        ]);
    }
}


Output:
Chunk ID: 574f53
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 218
Output:
<?php

namespace Database\Factories;

use App\Enums\ThemeStatus;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Theme>
 */
class ThemeFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => fake()->words(2, true).' theme',
            'version' => '1.0.0',
            'status' => ThemeStatus::Draft,
            'published_at' => null,
        ];
    }

    public function published(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ThemeStatus::Published,
            'published_at' => now(),
        ]);
    }
}


sed -n '1,220p' database/factories/ThemeSettingsFactory.php

<?php

namespace Database\Factories;

use App\Models\Theme;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ThemeSettings>
 */
class ThemeSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'theme_id' => Theme::factory(),
            'settings_json' => [
                'announcement' => [
                    'enabled' => true,
                    'text' => 'Free shipping on orders over 75.00 EUR',
                    'url' => null,
                ],
                'header' => [
                    'sticky' => true,
                    'main_menu' => 'main-menu',
                ],
                'footer' => [
                    'menu' => 'footer-menu',
                    'tagline' => 'A self-contained demo storefront.',
                ],
            ],
        ];
    }
}


Output:
Chunk ID: ed5c6f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 257
Output:
<?php

namespace Database\Factories;

use App\Models\Theme;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ThemeSettings>
 */
class ThemeSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'theme_id' => Theme::factory(),
            'settings_json' => [
                'announcement' => [
                    'enabled' => true,
                    'text' => 'Free shipping on orders over 75.00 EUR',
                    'url' => null,
                ],
                'header' => [
                    'sticky' => true,
                    'main_menu' => 'main-menu',
                ],
                'footer' => [
                    'menu' => 'footer-menu',
                    'tagline' => 'A self-contained demo storefront.',
                ],
            ],
        ];
    }
}


sed -n '1,260p' database/seeders/ThemeSeeder.php


sed -n '1,320p' tests/Feature/Admin/ThemeManagementTest.php


find app/Http/Controllers/Api/Admin -maxdepth 2 -type f | sort


find app/Http/Resources/Admin -maxdepth 2 -type f | sort


rg -n "write-themes|read-themes|ThemePolicy|themes" app tests specs/02-API-ROUTES.md


sed -n '1,260p' database/seeders/ThemeSeeder.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(),
                ],
            );
        });
    }
}


sed -n '1,320p' tests/Feature/Admin/ThemeManagementTest.php

sed: tests/Feature/Admin/ThemeManagementTest.php: No such file or directory


Output:
Chunk ID: 38cd2d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 181
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(),
                ],
            );
        });
    }
}


Output:
Chunk ID: 2e5c15
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 19
Output:
sed: tests/Feature/Admin/ThemeManagementTest.php: No such file or directory


Output:
Chunk ID: a9a4a8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 192
Output:
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php
app/Http/Controllers/Api/Admin/V1/CollectionController.php
app/Http/Controllers/Api/Admin/V1/CustomerController.php
app/Http/Controllers/Api/Admin/V1/DiscountController.php
app/Http/Controllers/Api/Admin/V1/OrderController.php
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php
app/Http/Controllers/Api/Admin/V1/PageController.php
app/Http/Controllers/Api/Admin/V1/ProductController.php
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php


Output:
Chunk ID: 8015db
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 137
Output:
app/Http/Resources/Admin/V1/CollectionResource.php
app/Http/Resources/Admin/V1/CustomerResource.php
app/Http/Resources/Admin/V1/DiscountResource.php
app/Http/Resources/Admin/V1/FulfillmentResource.php
app/Http/Resources/Admin/V1/OrderResource.php
app/Http/Resources/Admin/V1/PageResource.php
app/Http/Resources/Admin/V1/ProductResource.php
app/Http/Resources/Admin/V1/RefundResource.php
app/Http/Resources/Admin/V1/ShippingRateResource.php
app/Http/Resources/Admin/V1/ShippingZoneResource.php
app/Http/Resources/Admin/V1/TaxSettingsResource.php


/bin/zsh
-lc
sed -n '1,260p' routes/api.php

<?php

use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
use App\Http\Controllers\Api\Admin\V1\PageController as AdminPageController;
use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
use App\Http\Controllers\Api\Storefront\V1\CartController;
use App\Http\Controllers\Api\Storefront\V1\CartLineController;
use App\Http\Controllers\Api\Storefront\V1\CheckoutController;
use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
use Illuminate\Support\Facades\Route;

Route::middleware('store.resolve')
    ->prefix('storefront/v1')
    ->name('api.storefront.v1.')
    ->group(function (): void {
        Route::middleware('throttle:api.storefront')->group(function (): void {
            Route::post('carts', [CartController::class, 'store'])->name('carts.store');
            Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show');
            Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store');
            Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update');
            Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy');
        });

        Route::middleware('throttle:search')->group(function (): void {
            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
        });

        Route::middleware('throttle:analytics')->group(function (): void {
            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
        });

        Route::middleware('throttle:checkout')->group(function (): void {
            Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store');
            Route::get('checkouts/{checkout}', [CheckoutController::class, 'show'])->name('checkouts.show');
            Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'address'])->name('checkouts.address');
            Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('checkouts.shipping-method');
            Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount');
            Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'destroyDiscount'])->name('checkouts.discount.destroy');
            Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment-method');
            Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay');
            Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('admin/v1/stores/{store}')
    ->name('api.admin.v1.')
    ->group(function (): void {
        Route::middleware('admin.api:read-products')->group(function (): void {
            Route::get('products', [AdminProductController::class, 'index'])->name('products.index');
            Route::get('products/{product}', [AdminProductController::class, 'show'])->name('products.show');
        });

        Route::middleware('admin.api:read-customers')->group(function (): void {
            Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
            Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
        });

        Route::middleware('admin.api:read-collections')->group(function (): void {
            Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index');
        });

        Route::middleware('admin.api:write-collections')->group(function (): void {
            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
        });

        Route::middleware('admin.api:read-discounts')->group(function (): void {
            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
        });

        Route::middleware('admin.api:write-discounts')->group(function (): void {
            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
        });

        Route::middleware('admin.api:read-content')->group(function (): void {
            Route::get('pages', [AdminPageController::class, 'index'])->name('pages.index');
        });

        Route::middleware('admin.api:write-content')->group(function (): void {
            Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store');
            Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update');
            Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy');
        });

        Route::middleware('admin.api:read-settings')->group(function (): void {
            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
            Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
        });

        Route::middleware('admin.api:write-settings')->group(function (): void {
            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
            Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
        });

        Route::middleware('admin.api:read-analytics')->group(function (): void {
            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
        });

        Route::middleware('admin.api:read-orders')->group(function (): void {
            Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
            Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
        });

        Route::middleware('admin.api:write-orders')->group(function (): void {
            Route::post('orders/{order}/fulfillments', [AdminOrderFulfillmentController::class, 'store'])->name('orders.fulfillments.store');
            Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('apps/v1/stores/{store}')
    ->name('api.apps.v1.')
    ->group(function (): void {
        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
    });


Output:
Chunk ID: 58606f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2351
Output:
<?php

use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
use App\Http\Controllers\Api\Admin\V1\PageController as AdminPageController;
use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
use App\Http\Controllers\Api\Storefront\V1\CartController;
use App\Http\Controllers\Api\Storefront\V1\CartLineController;
use App\Http\Controllers\Api\Storefront\V1\CheckoutController;
use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
use Illuminate\Support\Facades\Route;

Route::middleware('store.resolve')
    ->prefix('storefront/v1')
    ->name('api.storefront.v1.')
    ->group(function (): void {
        Route::middleware('throttle:api.storefront')->group(function (): void {
            Route::post('carts', [CartController::class, 'store'])->name('carts.store');
            Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show');
            Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store');
            Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update');
            Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy');
        });

        Route::middleware('throttle:search')->group(function (): void {
            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
        });

        Route::middleware('throttle:analytics')->group(function (): void {
            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
        });

        Route::middleware('throttle:checkout')->group(function (): void {
            Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store');
            Route::get('checkouts/{checkout}', [CheckoutController::class, 'show'])->name('checkouts.show');
            Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'address'])->name('checkouts.address');
            Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('checkouts.shipping-method');
            Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount');
            Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'destroyDiscount'])->name('checkouts.discount.destroy');
            Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment-method');
            Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay');
            Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('admin/v1/stores/{store}')
    ->name('api.admin.v1.')
    ->group(function (): void {
        Route::middleware('admin.api:read-products')->group(function (): void {
            Route::get('products', [AdminProductController::class, 'index'])->name('products.index');
            Route::get('products/{product}', [AdminProductController::class, 'show'])->name('products.show');
        });

        Route::middleware('admin.api:read-customers')->group(function (): void {
            Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
            Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
        });

        Route::middleware('admin.api:read-collections')->group(function (): void {
            Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index');
        });

        Route::middleware('admin.api:write-collections')->group(function (): void {
            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
        });

        Route::middleware('admin.api:read-discounts')->group(function (): void {
            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
        });

        Route::middleware('admin.api:write-discounts')->group(function (): void {
            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
        });

        Route::middleware('admin.api:read-content')->group(function (): void {
            Route::get('pages', [AdminPageController::class, 'index'])->name('pages.index');
        });

        Route::middleware('admin.api:write-content')->group(function (): void {
            Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store');
            Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update');
            Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy');
        });

        Route::middleware('admin.api:read-settings')->group(function (): void {
            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
            Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
        });

        Route::middleware('admin.api:write-settings')->group(function (): void {
            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
            Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
        });

        Route::middleware('admin.api:read-analytics')->group(function (): void {
            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
        });

        Route::middleware('admin.api:read-orders')->group(function (): void {
            Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
            Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
        });

        Route::middleware('admin.api:write-orders')->group(function (): void {
            Route::post('orders/{order}/fulfillments', [AdminOrderFulfillmentController::class, 'store'])->name('orders.fulfillments.store');
            Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('apps/v1/stores/{store}')
    ->name('api.apps.v1.')
    ->group(function (): void {
        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
    });


rg -n "write-themes|read-themes|ThemePolicy|themes" app tests specs/02-API-ROUTES.md

rg -n 'write-themes|read-themes|ThemePolicy|themes' app tests specs/02-API-ROUTES.md

Question: write-themes|read-themes|ThemePolicy|themes

specs/02-API-ROUTES.md:100:| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
specs/02-API-ROUTES.md:101:| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |
specs/02-API-ROUTES.md:2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2502:#### POST /api/admin/v1/stores/{storeId}/themes
specs/02-API-ROUTES.md:2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
specs/02-API-ROUTES.md:2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
tests/Feature/Admin/ContentManagementTest.php:55:test('content routes render store scoped pages navigation and themes', function (): void {
tests/Feature/Admin/ContentManagementTest.php:87:        ->get('/admin/themes')
tests/Feature/Admin/ContentManagementTest.php:93:        ->get('/admin/themes/'.$theme->getKey().'/editor')
tests/Feature/Admin/ContentManagementTest.php:201:test('themes can be duplicated edited and published', function (): void {
tests/Feature/Foundation/AuthorizationTest.php:23:use App\Policies\ThemePolicy;
tests/Feature/Foundation/AuthorizationTest.php:124:        ThemePolicy::class => [
tests/Feature/Storefront/ThemeDataTest.php:27:    expect(Schema::hasColumns('themes', ['store_id', 'name', 'version', 'status', 'published_at']))->toBeTrue()
app/Models/Store.php:68:    public function themes(): HasMany
app/Livewire/Admin/Themes/Editor.php:188:        return view('livewire.admin.themes.editor', [
app/Livewire/Admin/Themes/Index.php:84:                    $storageKey = "themes/{$copy->getKey()}/{$file->path}";
app/Livewire/Admin/Themes/Index.php:114:            $this->addError('theme', __('Published themes cannot be deleted.'));
app/Livewire/Admin/Themes/Index.php:127:    public function themes(): Collection
app/Livewire/Admin/Themes/Index.php:144:        return view('livewire.admin.themes.index', [
app/Livewire/Admin/Themes/Index.php:145:            'themes' => $this->themes(),
app/Policies/ThemePolicy.php:9:class ThemePolicy


Output:
Chunk ID: 3cde95
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 642
Output:
specs/02-API-ROUTES.md:100:| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
specs/02-API-ROUTES.md:101:| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |
specs/02-API-ROUTES.md:2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2502:#### POST /api/admin/v1/stores/{storeId}/themes
specs/02-API-ROUTES.md:2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
specs/02-API-ROUTES.md:2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
tests/Feature/Admin/ContentManagementTest.php:55:test('content routes render store scoped pages navigation and themes', function (): void {
tests/Feature/Admin/ContentManagementTest.php:87:        ->get('/admin/themes')
tests/Feature/Admin/ContentManagementTest.php:93:        ->get('/admin/themes/'.$theme->getKey().'/editor')
tests/Feature/Admin/ContentManagementTest.php:201:test('themes can be duplicated edited and published', function (): void {
tests/Feature/Foundation/AuthorizationTest.php:23:use App\Policies\ThemePolicy;
tests/Feature/Foundation/AuthorizationTest.php:124:        ThemePolicy::class => [
tests/Feature/Storefront/ThemeDataTest.php:27:    expect(Schema::hasColumns('themes', ['store_id', 'name', 'version', 'status', 'published_at']))->toBeTrue()
app/Models/Store.php:68:    public function themes(): HasMany
app/Livewire/Admin/Themes/Editor.php:188:        return view('livewire.admin.themes.editor', [
app/Livewire/Admin/Themes/Index.php:84:                    $storageKey = "themes/{$copy->getKey()}/{$file->path}";
app/Livewire/Admin/Themes/Index.php:114:            $this->addError('theme', __('Published themes cannot be deleted.'));
app/Livewire/Admin/Themes/Index.php:127:    public function themes(): Collection
app/Livewire/Admin/Themes/Index.php:144:        return view('livewire.admin.themes.index', [
app/Livewire/Admin/Themes/Index.php:145:            'themes' => $this->themes(),
app/Policies/ThemePolicy.php:9:class ThemePolicy


sed -n '180,280p' tests/Feature/Admin/ContentManagementTest.php


sed -n '1,180p' app/Policies/ThemePolicy.php


sed -n '2490,2588p' specs/02-API-ROUTES.md


sed -n '1,240p' tests/Feature/Api/Admin/AdminShippingSettingsApiTest.php


sed -n '1,220p' app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php


sed -n '180,280p' tests/Feature/Admin/ContentManagementTest.php

$shopIndex = collect($component->get('menuItems'))
        ->search(fn (array $item): bool => $item['label'] === 'Shop');

    expect($shopIndex)->not->toBeFalse();

    $component
        ->call('removeItem', (int) $shopIndex)
        ->call('saveMenu')
        ->assertHasNoErrors();

    expect(NavigationItem::withoutGlobalScopes()
        ->where('menu_id', $menu->getKey())
        ->where('label', 'Shop')
        ->exists())->toBeFalse()
        ->and(NavigationItem::withoutGlobalScopes()
            ->where('menu_id', $menu->getKey())
            ->where('label', 'Lookbook')
            ->exists())->toBeFalse();
});

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);


Output:
Chunk ID: 77e3fc
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1076
Output:

    $shopIndex = collect($component->get('menuItems'))
        ->search(fn (array $item): bool => $item['label'] === 'Shop');

    expect($shopIndex)->not->toBeFalse();

    $component
        ->call('removeItem', (int) $shopIndex)
        ->call('saveMenu')
        ->assertHasNoErrors();

    expect(NavigationItem::withoutGlobalScopes()
        ->where('menu_id', $menu->getKey())
        ->where('label', 'Shop')
        ->exists())->toBeFalse()
        ->and(NavigationItem::withoutGlobalScopes()
            ->where('menu_id', $menu->getKey())
            ->where('label', 'Lookbook')
            ->exists())->toBeFalse();
});

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);


sed -n '1,180p' app/Policies/ThemePolicy.php

<?php

namespace App\Policies;

use App\Models\Theme;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ThemePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->isOwnerOrAdmin($user);
    }

    public function view(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }

    public function create(User $user): bool
    {
        return $this->isOwnerOrAdmin($user);
    }

    public function update(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }

    public function publish(User $user, Theme $theme): bool
    {
        return $this->update($user, $theme);
    }

    public function delete(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }
}


Output:
Chunk ID: f122f2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 232
Output:
<?php

namespace App\Policies;

use App\Models\Theme;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ThemePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->isOwnerOrAdmin($user);
    }

    public function view(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }

    public function create(User $user): bool
    {
        return $this->isOwnerOrAdmin($user);
    }

    public function update(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }

    public function publish(User $user, Theme $theme): bool
    {
        return $this->update($user, $theme);
    }

    public function delete(User $user, Theme $theme): bool
    {
        return $this->isOwnerOrAdmin($user, $this->storeIdForModel($theme));
    }
}


/bin/zsh
-lc
sed -n '1,130p' app/Models/Store.php

<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    /** @use HasFactory<\Database\Factories\StoreFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'organization_id',
        'name',
        'handle',
        'status',
        'default_currency',
        'default_locale',
        'timezone',
    ];

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

    /**
     * @return HasMany<StoreDomain, $this>
     */
    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    /**
     * @return BelongsToMany<User, $this>
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role');
    }

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

    /**
     * @return HasMany<Theme, $this>
     */
    public function themes(): HasMany
    {
        return $this->hasMany(Theme::class);
    }

    /**
     * @return HasMany<Page, $this>
     */
    public function pages(): HasMany
    {
        return $this->hasMany(Page::class);
    }

    /**
     * @return HasMany<NavigationMenu, $this>
     */
    public function navigationMenus(): HasMany
    {
        return $this->hasMany(NavigationMenu::class);
    }

    /**
     * @return HasMany<Cart, $this>
     */
    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }

    /**
     * @return HasMany<Checkout, $this>
     */
    public function checkouts(): HasMany
    {
        return $this->hasMany(Checkout::class);
    }

    /**
     * @return HasMany<ShippingZone, $this>
     */
    public function shippingZones(): HasMany
    {
        return $this->hasMany(ShippingZone::class);
    }

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

    /**
     * @return HasMany<Discount, $this>
     */
    public function discounts(): HasMany
    {
        return $this->hasMany(Discount::class);
    }

    /**
     * @return HasMany<Order, $this>


Output:
Chunk ID: 20e27a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 693
Output:
<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    /** @use HasFactory<\Database\Factories\StoreFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'organization_id',
        'name',
        'handle',
        'status',
        'default_currency',
        'default_locale',
        'timezone',
    ];

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

    /**
     * @return HasMany<StoreDomain, $this>
     */
    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    /**
     * @return BelongsToMany<User, $this>
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role');
    }

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

    /**
     * @return HasMany<Theme, $this>
     */
    public function themes(): HasMany
    {
        return $this->hasMany(Theme::class);
    }

    /**
     * @return HasMany<Page, $this>
     */
    public function pages(): HasMany
    {
        return $this->hasMany(Page::class);
    }

    /**
     * @return HasMany<NavigationMenu, $this>
     */
    public function navigationMenus(): HasMany
    {
        return $this->hasMany(NavigationMenu::class);
    }

    /**
     * @return HasMany<Cart, $this>
     */
    public function carts(): HasMany
    {
        return $this->hasMany(Cart::class);
    }

    /**
     * @return HasMany<Checkout, $this>
     */
    public function checkouts(): HasMany
    {
        return $this->hasMany(Checkout::class);
    }

    /**
     * @return HasMany<ShippingZone, $this>
     */
    public function shippingZones(): HasMany
    {
        return $this->hasMany(ShippingZone::class);
    }

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

    /**
     * @return HasMany<Discount, $this>
     */
    public function discounts(): HasMany
    {
        return $this->hasMany(Discount::class);
    }

    /**
     * @return HasMany<Order, $this>


sed -n '2490,2588p' specs/02-API-ROUTES.md

---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |


Output:
Chunk ID: 604809
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 621
Output:
---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",
    "version": "1.0.0",
    "status": "draft",
    "published_at": null,
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid theme archive (missing required templates, invalid manifest)

---

#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish

Publish a theme, making it the active storefront theme. The previously published theme transitions to `draft`.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "published",
    "published_at": "2026-02-11T10:05:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Theme not found
- `422` - Theme has validation errors preventing publish

---

#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings

Update theme settings (customization values).

**Request body:**

```json
{
  "settings_json": {
    "colors": {
      "primary": "#1a73e8",
      "secondary": "#fbbc04",
      "background": "#ffffff",
      "text": "#202124"
    },
    "typography": {
      "heading_font": "Inter",
      "body_font": "Inter"
    },
    "header": {
      "show_announcement_bar": true,
      "announcement_text": "Free shipping on orders over 50 EUR!"
    }
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `settings_json` | object | Yes | Must conform to the theme's settings schema. |


sed -n '1,240p' tests/Feature/Api/Admin/AdminShippingSettingsApiTest.php

sed: tests/Feature/Api/Admin/AdminShippingSettingsApiTest.php: No such file or directory