command: rg -n -B2 -A4 'MultipleRootElements|local.ERROR' storage/logs/laravel.log | tail -40

type: search

query: MultipleRootElements|local.ERROR

stdout:
--
745-#132 {main}
746-"}
747:[2026-08-20 21:22:03] local.ERROR: The "--compact" option does not exist. {"exception":"[object] (Symfony\\Component\\Console\\Exception\\RuntimeException(code: 0): The \"--compact\" option does not exist. at /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php:226)
748-[stacktrace]
749-#0 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(155): Symfony\\Component\\Console\\Input\\ArgvInput->addLongOption('compact', NULL)
750-#1 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(88): Symfony\\Component\\Console\\Input\\ArgvInput->parseLongOption('--compact')
751-#2 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(77): Symfony\\Component\\Console\\Input\\ArgvInput->parseToken('--compact', true)
--
935-#84 {main}
936-"}
937:[2026-08-20 21:22:52] testing.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
938-[stacktrace]
939-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<!DOCTYPE html>...')
940-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<!DOCTYPE html>...', Array)
941-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<!DOCTYPE html>...', Array)
--
1458-#115 {main}
1459-"}
1460:[2026-08-20 21:27:02] local.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
1461-[stacktrace]
1462-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<section wire:s...')
1463-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<section wire:s...', Array)
1464-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<section wire:s...', Array)
--
1530-#68 {main}
1531-"}
1532:[2026-08-20 21:27:02] local.ERROR: SQLSTATE[HY000]: General error: 1 no such column: variant_option_values.product_variant_id (Connection: sqlite, Database: /Users/wesner/Herd/shop/database/database.sqlite, SQL: select * from "variant_option_values" where "variant_option_values"."product_variant_id" = 1) {"exception":"[object] (Illuminate\\Database\\QueryException(code: HY000): SQLSTATE[HY000]: General error: 1 no such column: variant_option_values.product_variant_id (Connection: sqlite, Database: /Users/wesner/Herd/shop/database/database.sqlite, SQL: select * from \"variant_option_values\" where \"variant_option_values\".\"product_variant_id\" = 1) at /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php:838)
1533-[stacktrace]
1534-#0 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php(794): Illuminate\\Database\\Connection->runQueryCallback('select * from \"...', Array, Object(Closure))
1535-#1 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php(411): Illuminate\\Database\\Connection->run('select * from \"...', Array, Object(Closure))
1536-#2 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php(3438): Illuminate\\Database\\Connection->select('select * from \"...', Array, true)
--
1654-#59 {main}
1655-"}
1656:[2026-08-20 21:27:22] local.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
1657-[stacktrace]
1658-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<section wire:s...')
1659-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<section wire:s...', Array)
1660-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<section wire:s...', Array)


text:
Script completed
Wall time 0.1 seconds
Output:

text:
--
745-#132 {main}
746-"}
747:[2026-08-20 21:22:03] local.ERROR: The "--compact" option does not exist. {"exception":"[object] (Symfony\\Component\\Console\\Exception\\RuntimeException(code: 0): The \"--compact\" option does not exist. at /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php:226)
748-[stacktrace]
749-#0 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(155): Symfony\\Component\\Console\\Input\\ArgvInput->addLongOption('compact', NULL)
750-#1 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(88): Symfony\\Component\\Console\\Input\\ArgvInput->parseLongOption('--compact')
751-#2 /Users/wesner/Herd/shop/vendor/symfony/console/Input/ArgvInput.php(77): Symfony\\Component\\Console\\Input\\ArgvInput->parseToken('--compact', true)
--
935-#84 {main}
936-"}
937:[2026-08-20 21:22:52] testing.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
938-[stacktrace]
939-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<!DOCTYPE html>...')
940-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<!DOCTYPE html>...', Array)
941-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<!DOCTYPE html>...', Array)
--
1458-#115 {main}
1459-"}
1460:[2026-08-20 21:27:02] local.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
1461-[stacktrace]
1462-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<section wire:s...')
1463-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<section wire:s...', Array)
1464-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<section wire:s...', Array)
--
1530-#68 {main}
1531-"}
1532:[2026-08-20 21:27:02] local.ERROR: SQLSTATE[HY000]: General error: 1 no such column: variant_option_values.product_variant_id (Connection: sqlite, Database: /Users/wesner/Herd/shop/database/database.sqlite, SQL: select * from "variant_option_values" where "variant_option_values"."product_variant_id" = 1) {"exception":"[object] (Illuminate\\Database\\QueryException(code: HY000): SQLSTATE[HY000]: General error: 1 no such column: variant_option_values.product_variant_id (Connection: sqlite, Database: /Users/wesner/Herd/shop/database/database.sqlite, SQL: select * from \"variant_option_values\" where \"variant_option_values\".\"product_variant_id\" = 1) at /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php:838)
1533-[stacktrace]
1534-#0 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php(794): Illuminate\\Database\\Connection->runQueryCallback('select * from \"...', Array, Object(Closure))
1535-#1 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Connection.php(411): Illuminate\\Database\\Connection->run('select * from \"...', Array, Object(Closure))
1536-#2 /Users/wesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php(3438): Illuminate\\Database\\Connection->select('select * from \"...', Array, true)
--
1654-#59 {main}
1655-"}
1656:[2026-08-20 21:27:22] local.ERROR: Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] {"exception":"[object] (Livewire\\Features\\SupportMultipleRootElementDetection\\MultipleRootElementsDetectedException(code: 0): Livewire only supports one HTML element per component. Multiple root elements detected for component: [storefront.home] at /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php:26)
1657-[stacktrace]
1658-#0 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportMultipleRootElementDetection/SupportMultipleRootElementDetection.php(16): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection->warnAgainstMoreThanOneRootElement(Object(App\\Livewire\\Storefront\\Home), '<section wire:s...')
1659-#1 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/EventBus.php(100): Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::{closure:{closure:Livewire\\Features\\SupportMultipleRootElementDetection\\SupportMultipleRootElementDetection::provide():12}:15}('<section wire:s...', Array)
1660-#2 /Users/wesner/Herd/shop/vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php(96): Livewire\\EventBus->{closure:Livewire\\EventBus::trigger():94}('<section wire:s...', Array)


exec

status: completed

input: const r = await tools.exec_command({cmd:"sed -n '1,180p' app/Livewire/Storefront/Home.php; sed -n '1,240p' resources/views/livewire/storefront/home.blade.php; sed -n '1,120p' resources/views/livewire/storefront/home-fallback.blade.php 2>/dev/null || true",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:10000}); text(r.output);


type: CommandExecution

command: sed -n '1,180p' app/Livewire/Storefront/Home.php; sed -n '1,240p' resources/views/livewire/storefront/home.blade.php; sed -n '1,120p' resources/views/livewire/storefront/home-fallback.blade.php 2>/dev/null || true

type: unknown

status: completed

stdout:
<?php

namespace App\Livewire\Storefront;

use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use Livewire\Component;

class Home extends Component
{
    public ?Store $store = null;

    public function mount(): void
    {
        $this->store = app()->bound('current_store') ? app('current_store') : null;
    }

    public function render(): mixed
    {
        if ($this->store === null) {
            return view('livewire.storefront.home-fallback')->layout('layouts.empty');
        }

        return view('livewire.storefront.home', ['collections' => Collection::query()->where('status', 'active')->withCount('products')->latest()->take(4)->get(), 'products' => Product::query()->published()->with(['variants.inventory', 'media'])->latest('published_at')->take(8)->get()])->layout('layouts.storefront');
    }
}

Acme Fashion

Everyday pieces, thoughtfully made.

Timeless wardrobe essentials with an easy, modern fit.

            Shop new arrivals

Explore

Featured collections

View all

@foreach ($collections as $collection)id }}" href="{{ route('collection.show', $collection->handle) }}" class="rounded-2xl bg-zinc-100 p-6 transition hover:-translate-y-1 dark:bg-zinc-800" wire:navigate>
{{ $collection->title }}

{{ $collection->products_count }} products
@endforeach

Curated for you

Featured products

@foreach ($products as $product)<x-storefront.product-card :product="$product" />@endforeach

Stay in the loop

Subscribe for exclusive offers and updates.
<form class="mx-auto mt-6 flex max-w-lg gap-3" action="#" method="post">Email<input id="newsletter-email" type="email" placeholder="Enter your email" class="min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button type="submit" class="rounded-full bg-blue-600 px-5 py-3 font-semibold text-white">Subscribe</button></form>

Laravel


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Livewire\Storefront;

use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use Livewire\Component;

class Home extends Component
{
    public ?Store $store = null;

    public function mount(): void
    {
        $this->store = app()->bound('current_store') ? app('current_store') : null;
    }

    public function render(): mixed
    {
        if ($this->store === null) {
            return view('livewire.storefront.home-fallback')->layout('layouts.empty');
        }

        return view('livewire.storefront.home', ['collections' => Collection::query()->where('status', 'active')->withCount('products')->latest()->take(4)->get(), 'products' => Product::query()->published()->with(['variants.inventory', 'media'])->latest('published_at')->take(8)->get()])->layout('layouts.storefront');
    }
}

Acme Fashion

Everyday pieces, thoughtfully made.

Timeless wardrobe essentials with an easy, modern fit.

            Shop new arrivals

Explore

Featured collections

View all

@foreach ($collections as $collection)id }}" href="{{ route('collection.show', $collection->handle) }}" class="rounded-2xl bg-zinc-100 p-6 transition hover:-translate-y-1 dark:bg-zinc-800" wire:navigate>
{{ $collection->title }}

{{ $collection->products_count }} products
@endforeach

Curated for you

Featured products

@foreach ($products as $product)<x-storefront.product-card :product="$product" />@endforeach

Stay in the loop

Subscribe for exclusive offers and updates.
<form class="mx-auto mt-6 flex max-w-lg gap-3" action="#" method="post">Email<input id="newsletter-email" type="email" placeholder="Enter your email" class="min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button type="submit" class="rounded-full bg-blue-600 px-5 py-3 font-semibold text-white">Subscribe</button></form>

Laravel


status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/resources/views/livewire/storefront/home.blade.php\n@@\n-
\n+
\n+
\n@@\n
\n+
\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/resources/views/livewire/storefront/home.blade.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input: const r = await tools.exec_command({cmd:"sed -n '1,140p' resources/views/livewire/storefront/home.blade.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:5000}); text(r.output);


command: sed -n '1,140p' resources/views/livewire/storefront/home.blade.php

home.blade.php

type: read

stdout:
Acme Fashion

Everyday pieces, thoughtfully made.

Timeless wardrobe essentials with an easy, modern fit.

            Shop new arrivals

Explore

Featured collections

View all

@foreach ($collections as $collection)id }}" href="{{ route('collection.show', $collection->handle) }}" class="rounded-2xl bg-zinc-100 p-6 transition hover:-translate-y-1 dark:bg-zinc-800" wire:navigate>
{{ $collection->title }}

{{ $collection->products_count }} products
@endforeach

Curated for you

Featured products

@foreach ($products as $product)<x-storefront.product-card :product="$product" />@endforeach

Stay in the loop

Subscribe for exclusive offers and updates.
<form class="mx-auto mt-6 flex max-w-lg gap-3" action="#" method="post">Email<input id="newsletter-email" type="email" placeholder="Enter your email" class="min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button type="submit" class="rounded-full bg-blue-600 px-5 py-3 font-semibold text-white">Subscribe</button></form>


text:
Script completed
Wall time 0.1 seconds
Output:

text:
Acme Fashion

Everyday pieces, thoughtfully made.

Timeless wardrobe essentials with an easy, modern fit.

            Shop new arrivals

Explore

Featured collections

View all

@foreach ($collections as $collection)id }}" href="{{ route('collection.show', $collection->handle) }}" class="rounded-2xl bg-zinc-100 p-6 transition hover:-translate-y-1 dark:bg-zinc-800" wire:navigate>
{{ $collection->title }}

{{ $collection->products_count }} products
@endforeach

Curated for you

Featured products

@foreach ($products as $product)<x-storefront.product-card :product="$product" />@endforeach

Stay in the loop

Subscribe for exclusive offers and updates.
<form class="mx-auto mt-6 flex max-w-lg gap-3" action="#" method="post">Email<input id="newsletter-email" type="email" placeholder="Enter your email" class="min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button type="submit" class="rounded-full bg-blue-600 px-5 py-3 font-semibold text-white">Subscribe</button></form>


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/resources/views/livewire/storefront/home.blade.php\n@@\n-
\n
\n@@\n

Stay in the loop

Subscribe for exclusive offers and updates.
<form class=\"mx-auto mt-6 flex max-w-lg gap-3\" action=\"#\" method=\"post\">Email<input id=\"newsletter-email\" type=\"email\" placeholder=\"Enter your email\" class=\"min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900\"><button type=\"submit\" class=\"rounded-full bg-blue-600 px-5 py-3 font-semibold text-white\">Subscribe</button></form>
\n+
\n*** End Patch";
text(await tools.apply_patch(patch));


unified_diff:
@@ -11,3 +11,2 @@

-

@@ -18 +17,2 @@

Stay in the loop

Subscribe for exclusive offers and updates.
<form class="mx-auto mt-6 flex max-w-lg gap-3" action="#" method="post">Email<input id="newsletter-email" type="email" placeholder="Enter your email" class="min-w-0 flex-1 rounded-full border border-zinc-300 bg-white px-5 py-3 dark:border-zinc-700 dark:bg-zinc-900"><button type="submit" class="rounded-full bg-blue-600 px-5 py-3 font-semibold text-white">Subscribe</button></form>

+


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"curl -sS -o /tmp/shop-home.html -w '%{http_code}\\n' -H 'Host: shop.test' http://127.0.0.1:8000/ && rg -n 'Everyday pieces|Featured collections|Classic Cotton' /tmp/shop-home.html | head -5",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:4000}),
  tools.exec_command({cmd:"php artisan view:cache",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:5000}),
  tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:5000})
]);
for (const r of results) text(r.output);


command: curl -sS -o /tmp/shop-home.html -w '%{http_code}\n' -H 'Host: shop.test' http://127.0.0.1:8000/ && rg -n 'Everyday pieces|Featured collections|Classic Cotton' /tmp/shop-home.html | head -5

stdout:
200
247:
Everyday pieces, thoughtfully made.

254:

Explore

Featured collections

View all

291:
296:
Classic Cotton T-Shirt


command:
/bin/zsh
-lc
php artisan view:cache

cmd: php artisan view:cache

stdout: INFO  Blade templates cached successfully.


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

cmd: vendor/bin/pint --dirty --format agent

result: fixed


text:
Script completed
Wall time 1.1 seconds
Output:

text:
200
247:
Everyday pieces, thoughtfully made.

254:

Explore

Featured collections

View all

291:
296:
Classic Cotton T-Shirt

text: INFO  Blade templates cached successfully.

text: {"result":"fixed","files":[{"path":"app\/Http\/Middleware\/ResolveStore.php","fixers":["unary_operator_spaces","not_operator_with_successor_space","ordered_imports"]}]}


type: AgentMessage

type: Text

text: The first smoke pass caught two real integration defects: the seeded variant-option pivot used the wrong relationship keys, and the homepage had multiple Livewire roots. Both are fixed; the app now seeds cleanly, builds with Vite, and renders the storefront, product, and admin-login pages successfully.

phase: commentary


type: message

role: assistant


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"sed -n '1,260p' specs/09-IMPLEMENTATION-ROADMAP.md",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:12000}),
  tools.exec_command({cmd:"sed -n '1,300p' specs/05-BUSINESS-LOGIC.md",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:14000}),
  tools.exec_command({cmd:"sed -n '1,280p' specs/02-API-ROUTES.md",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:14000}),
  tools.exec_command({cmd:"sed -n '1,260p' specs/01-DATABASE-SCHEMA.md",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:14000})
]);
for (const r of results) text(r.output);


command:
/bin/zsh
-lc
sed -n '1,280p' specs/02-API-ROUTES.md

cmd: sed -n '1,280p' specs/02-API-ROUTES.md

stdout:
# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

| Method | Path | Description | Component / View |
|--------|------|-------------|------------------|
| GET | `/` | Home page | `Storefront\Home` |
| GET | `/collections` | All collections listing | `Storefront\Collections\Index` |
| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\Collections\Show` |
| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |

#### Customer Account Routes

**Additional middleware:** `auth:customer`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |

#### Customer Auth Routes (no auth required)

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
| POST | `/reset-password` | Reset password | Handled by Livewire action |

---

### 1.4 Checkout Routes

**Middleware:** `web`, `store.resolve`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |

---

## 2. Storefront REST API

**Prefix:** `/api/storefront/v1`
**Middleware:** `store.resolve`, `throttle:api.storefront`
**Content-Type:** `application/json`

These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).

---

### 2.1 Cart Endpoints

#### POST /api/storefront/v1/carts

Create a new cart.

- **Authentication:** None (guest) or session-based (logged-in customer)
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "currency": "EUR"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |

**Success response:** `201 Created`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 1,
  "status": "active",
  "lines": [],
  "totals": {
    "subtotal": 0,
    "discount": 0,
    "total": 0,
    "currency": "EUR"
  },
  "created_at": "2026-02-11T10:00:00Z",
  "updated_at": "2026-02-11T10:00:00Z"
}
```

**Error responses:**
- `422` - Validation error (invalid currency)

---

#### GET /api/storefront/v1/carts/{cartId}

Retrieve a cart with all lines and computed totals.

- **Authentication:** None (cart ID acts as token)
- **Rate limit:** `api.storefront` (120/min per IP)

**URL parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `cartId` | integer | Yes | The cart identifier |

**Success response:** `200 OK`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 3,
  "status": "active",
  "lines": [
    {
      "id": 201,


command: sed -n '1,260p' specs/09-IMPLEMENTATION-ROADMAP.md

stdout:
# 09 - Implementation Roadmap and Pest Test Specification

> E-commerce platform - Step-by-step build order and exhaustive test plan for Laravel 12 + Livewire v4 + Flux UI Free + SQLite + Tailwind CSS v4

---

## 1. Technology Decisions (Final)

These decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.

| Concern | PDF Default | Our Decision | Rationale |
|---------|-------------|--------------|-----------|
| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |
| Cache | Redis | File (Array for tests) | No Redis dependency |
| Session | Redis | File | No Redis dependency |
| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |
| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |
| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |
| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |
| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |
| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |
| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |
| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |
| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |
| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |
| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |
| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |
| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |
| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |

---

## 2. Implementation Phases (Build Order)

Each phase builds on the previous. A coding agent should execute these in strict sequential order. Tests are written alongside each phase (see Section 3 for the full test specification).

### Phase Dependency Graph

```mermaid
flowchart TD
    P1[Phase 1: Foundation]
    P2[Phase 2: Catalog]
    P3[Phase 3: Themes and Storefront Layout]
    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]
    P5[Phase 5: Payments, Orders, Fulfillment]
    P6[Phase 6: Customer Accounts]
    P7[Phase 7: Admin Panel]
    P8[Phase 8: Search]
    P9[Phase 9: Analytics]
    P10[Phase 10: Apps and Webhooks]
    P11[Phase 11: Polish]
    P12[Phase 12: Full Test Suite]

    P1 --> P2
    P1 --> P3
    P2 --> P4
    P3 --> P4
    P4 --> P5
    P5 --> P6
    P5 --> P7
    P2 --> P8
    P5 --> P9
    P5 --> P10
    P6 --> P11
    P7 --> P11
    P8 --> P11
    P9 --> P11
    P10 --> P11
    P11 --> P12
```

---

### Phase 1: Foundation (Migrations, Models, Middleware, Auth)

**Priority:** CRITICAL - everything depends on this.

#### Step 1.1: Environment and Config

| Config File | Setting | Value | Purpose |
|-------------|---------|-------|---------|
| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |
| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |
| `.env` | `CACHE_STORE` | `file` | File-based caching |
| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |
| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |
| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |
| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |
| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |
| `config/session.php` | `driver` | `file` | Session driver |
| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |
| `config/cache.php` | `default` | `file` | Cache driver |
| `config/queue.php` | `default` | `sync` | Queue driver |
| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |
| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |
| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |
| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |
| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |

#### Step 1.2: Core Migrations (Batch 1-2)

Create in this dependency order:

1. `create_organizations_table` - no dependencies
2. `create_stores_table` - FK: `organization_id`
3. `create_store_domains_table` - FK: `store_id`
4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally
5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column
6. `create_store_settings_table` - PK: `store_id`

All monetary amounts stored as INTEGER in minor units (cents). All enums stored as TEXT with CHECK constraints. All timestamps as TEXT (ISO-8601) via Laravel.

#### Step 1.3: Core Models

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |
| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |
| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |
| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |
| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |

**Relationship definitions:**

| Model | Key Relationships |
|-------|-------------------|
| `Organization` | `hasMany(Store::class)` |
| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |
| `StoreDomain` | `belongsTo(Store::class)` |
| `User` | `belongsToMany(Store::class)` through `store_users` |
| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |
| `StoreSettings` | `belongsTo(Store::class)` |

Each model must have:
- Explicit return type declarations on all methods
- Proper `$fillable` or `$guarded` arrays
- `casts()` method for JSON/enum columns
- Factory with meaningful default values
- Seeder that creates sample data

#### Step 1.4: Enums

**Files to create in `app/Enums/`:**

| Enum | Values |
|------|--------|
| `StoreStatus` | `Active`, `Suspended` |
| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |
| `StoreDomainType` | `Storefront`, `Admin`, `Api` |

#### Step 1.5: Tenant Resolution Middleware

**File to create:** `App\Http\Middleware\ResolveStore`

Requirements:
1. Storefront routes: extract hostname from the request, look up `store_domains`, cache hostname-to-store_id for 5 minutes, bind `Store` as singleton via container instance `current_store`
2. Admin routes: read `current_store_id` from session, verify user has `store_users` record
3. Return 404 if hostname not found
4. Return 503 for suspended stores on storefront routes

**Registration in `bootstrap/app.php`:**
- Register middleware group `storefront` containing `ResolveStore`
- Register middleware group `admin` containing `ResolveStore`

#### Step 1.6: BelongsToStore Trait and Global Scope

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Trait | `App\Models\Concerns\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |
| Scope | `App\Models\Scopes\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |

Applied on: products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.

#### Step 1.7: Authentication

**Admin auth requirements:**

| Component | Description |
|-----------|-------------|
| Livewire component `Admin\Auth\Login` | Admin login form |
| Livewire component `Admin\Auth\Logout` | Admin logout action |
| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |
| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |
| Password reset | Standard Laravel flow with `Password::broker('users')` |

**Customer auth requirements:**

| Component | Description |
|-----------|-------------|
| Custom guard | `customer` guard with `customers` provider |
| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |
| Livewire component `Storefront\Account\Auth\Login` | Customer login form |
| Livewire component `Storefront\Account\Auth\Register` | Customer registration form |
| Uniqueness | Customer email unique per store, not globally |
| Rate limiting | 5 attempts per minute per IP |

**Rate limiter registration:**
- Register rate limiters in `AppServiceProvider::boot()` for login (5 per minute per IP)

#### Step 1.8: Authorization

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Policy | `ProductPolicy` | Product authorization based on store role |
| Policy | `OrderPolicy` | Order authorization based on store role |
| Policy | `CollectionPolicy` | Collection authorization based on store role |
| Policy | `DiscountPolicy` | Discount authorization based on store role |
| Policy | `CustomerPolicy` | Customer authorization based on store role |
| Policy | `StorePolicy` | Store authorization based on store role |
| Policy | `PagePolicy` | Page authorization based on store role |
| Policy | `ThemePolicy` | Theme authorization based on store role |
| Policy | `FulfillmentPolicy` | Fulfillment authorization based on store role |
| Policy | `RefundPolicy` | Refund authorization based on store role |

Each policy checks user role via `store_users` pivot for the current store. Helper on User model: `roleForStore(Store $store): ?StoreUserRole`. Register policies via auto-discovery or explicit binding in `AppServiceProvider`.

**Permission Matrix:**

| Permission | Owner | Admin | Staff | Support |
|------------|:-----:|:-----:|:-----:|:-------:|
| Manage store settings | Y | Y | N | N |
| Delete store | Y | N | N | N |
| Manage staff | Y | Y | N | N |
| Manage products (CRUD) | Y | Y | Y | N |
| Delete/archive products | Y | Y | N | N |
| Manage orders | Y | Y | Y | Read-only |
| Process refunds | Y | Y | N | N |
| Create fulfillments | Y | Y | Y | N |
| Manage discounts | Y | Y | Y | N |
| Manage themes | Y | Y | N | N |
| Manage apps | Y | Y | N | N |
| View analytics | Y | Y | Y | N |
| Manage shipping/tax | Y | Y | N | N |
| View customers | Y | Y | Y | Y |
| Update customers | Y | Y | Y | N |

---

### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)

**Priority:** HIGH - storefront and orders depend on this.

#### Step 2.1: Migrations (Batch 3-5)

Create in dependency order:

1. `create_products_table` - FK: `store_id`
2. `create_product_options_table` - FK: `product_id`
3. `create_product_option_values_table` - FK: `product_option_id`
4. `create_product_variants_table` - FK: `product_id`
5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`
6. `create_inventory_items_table` - FK: `store_id`, `variant_id`
7. `create_collections_table` - FK: `store_id`
8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`


command: sed -n '1,300p' specs/05-BUSINESS-LOGIC.md

stdout:
# 05 - Business Logic Specification

> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
| Rounding | Round each line independently, then sum (prevents rounding drift) |
| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |

---

## 1. Multi-Tenancy and Store Isolation

### 1.1 Tenant Resolution Middleware

**Class:** `App\Http\Middleware\ResolveStore`

**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.

#### Storefront Route Resolution (from request hostname)

1. Extract hostname from the incoming HTTP request.
2. Look up `store_domains` table where `hostname` matches.
3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
5. If hostname is not found in `store_domains`: return HTTP 404.
6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.

#### Admin Route Resolution (from session)

1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
2. Verify the authenticated user has a `store_users` record for that store.
3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.

```mermaid
flowchart TD
    A[Incoming Request] --> B{Route type?}
    B -->|Storefront| C[Extract hostname]
    C --> D{Hostname in cache?}
    D -->|Yes| E[Load store from cache]
    D -->|No| F[Query store_domains table]
    F --> G{Found?}
    G -->|No| H[Return 404]
    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    I --> E
    E --> J{Store suspended?}
    J -->|Yes| K[Return 503 maintenance]
    J -->|No| L[Bind store as singleton, continue]

    B -->|Admin| M[Read store_id from session]
    M --> N{User has store_users record?}
    N -->|No| O[Return 403]
    N -->|Yes| P{Store suspended?}
    P -->|Yes| Q[Return 403 for mutations]
    P -->|No| L
```

### 1.2 Store Scoping

**Trait:** `App\Models\Concerns\BelongsToStore`
**Global Scope:** `App\Models\Scopes\StoreScope`

The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.

**Behavior:**

- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.

**Models that must use this trait:**
products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.

### 1.3 Role-Based Access Control

**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`

**Permission Matrix:**

| Permission | Owner | Admin | Staff | Support |
|---|---|---|---|---|
| Manage store settings | Yes | Yes | No | No |
| Manage staff | Yes | Yes | No | No |
| Manage products | Yes | Yes | Yes | No |
| Manage orders | Yes | Yes | Yes | Yes (read-only) |
| Manage discounts | Yes | Yes | Yes | No |
| Manage themes | Yes | Yes | No | No |
| Manage apps | Yes | Yes | No | No |
| View analytics | Yes | Yes | Yes | No |
| Manage shipping/tax | Yes | Yes | No | No |
| Process refunds | Yes | Yes | No | No |
| Create fulfillments | Yes | Yes | Yes | No |

**Implementation requirements:**

- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
- Register gate definitions in a service provider.
- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.

---

## 2. Product Lifecycle

### 2.1 Status Transitions

**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`

```mermaid
stateDiagram-v2
    [*] --> Draft : Product created
    Draft --> Active : Publish (requires variant with price > 0, title not empty)
    Draft --> Archived : Archive
    Active --> Archived : Archive (hide from storefront)
    Archived --> Active : Re-publish (same preconditions as Draft->Active)
    Active --> Draft : Revert (BLOCKED if order_lines exist)
    Archived --> Draft : Revert (BLOCKED if order_lines exist)
```

**Transition rules:**

| From | To | Preconditions |
|---|---|---|
| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
| Draft | Archived | None |
| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
| Archived | Active | Same preconditions as Draft to Active |
| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |

**Deletion rules:**
- Only `draft` products with zero `order_lines` references may be hard-deleted.
- Products with existing order references must be archived, never deleted, to preserve order history integrity.

**Service:** `App\Services\ProductService`
- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
- On activation: set `published_at` to current time if not already set.
- Dispatch `ProductStatusChanged` event after successful transition.

### 2.2 Variant and Option Matrix

**Rules:**

- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
- Each option has 1 or more values, stored in `product_option_values`.
- Variants represent the cartesian product of option values (or a manually curated subset).
- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.

**Matrix rebuild logic (when options change):**

```
PROCEDURE rebuildMatrix(product):
    current_option_values = load all option values grouped by option
    desired_combos = cartesianProduct(current_option_values)
    existing_variants = load all variants with their option value sets

    FOR EACH combo IN desired_combos:
        IF combo matches an existing variant's option value set:
            SKIP (preserve existing variant - prices, SKUs, inventory intact)
        ELSE:
            CREATE new variant with default pricing from first existing variant

    FOR EACH existing_variant NOT matching any desired combo:
        IF variant has order_line references:
            ARCHIVE variant (set status = 'archived')
        ELSE:
            DELETE variant
```

**Service:** `App\Services\VariantMatrixService`

### 2.3 SKU Uniqueness

- Combination of store_id (derived through the product's store) and SKU must be unique.
- Enforced at application level before save.
- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.

### 2.4 Handle/Slug Generation

**Helper class:** `App\Support\HandleGenerator`

- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
- Merchants can manually edit the handle; on save, re-validate uniqueness.
- Same logic applies to collections and pages.

---

## 3. Inventory Management

### 3.1 Tracking Model

Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).

| Field | Description |
|---|---|
| `quantity_on_hand` | Total physical stock count |
| `quantity_reserved` | Units held for active checkouts or pending orders |
| **Available** (computed) | `quantity_on_hand - quantity_reserved` |

### 3.2 Inventory Policy

Per-variant policy stored in `inventory_items.policy`:

| Policy | Behavior |
|---|---|
| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |

### 3.3 Inventory Operations

**Service:** `App\Services\InventoryService`

All operations must execute within a database transaction.

```mermaid
flowchart LR
    subgraph "Stock Lifecycle"
        A[On Hand] -->|Reserve| B[Reserved]
        B -->|Commit on payment| C[Deducted]
        B -->|Release on expiry| A
        C -->|Restock on refund| A
    end
```

**Operation definitions:**

| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
|---|---|---|---|---|
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart
    MARK guestCart as abandoned
    RECALCULATE all line amounts on customerCart
```

### 4.2 Cart Operations

**Create cart:**
- Accepts: store, optional customer
- Sets: store_id, currency from store's default_currency, initial cart_version = 1

**Add line - validations (in order):**

1. Variant exists and belongs to the current store (through its product).
2. Product has status = `active`.
3. Variant has status = `active`.
4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
6. Set `unit_price_amount` from the variant's `price_amount`.
7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).


command: sed -n '1,260p' specs/01-DATABASE-SCHEMA.md

stdout:
# 01 - Database Schema Specification

> E-commerce platform - SQLite database schema for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
| JSON data | Text column - Laravel casts to/from JSON at the application layer |
| Enumerations | Check constraints on text columns |
| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | Organization display name |
| billing_email | TEXT | No | - | - | Primary billing contact |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email

---

### stores

Individual storefront belonging to an organization.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
| name | TEXT | No | - | - | Store display name |
| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| default_locale | TEXT | No | 'en' | - | IETF locale tag |
| timezone | TEXT | No | 'UTC' | - | IANA timezone |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status

---

### store_domains

Custom domains attached to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain

---

### users

Admin/staff users who manage stores (not storefront customers).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| email | TEXT | No | - | UNIQUE per table | Login email |
| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
| name | TEXT | No | - | - | Display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
- `idx_users_status` on (`status`) - Filtering users by active/disabled

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.

---

### store_users

Pivot table linking users to stores with a role. Uses composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Primary Key:** (`store_id`, `user_id`)

**Indexes:**

- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store

---

### store_settings

Single-row-per-store JSON settings bag.

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

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products

Core product record, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Product display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |
| product_type | TEXT | Yes | NULL | - | Category classification |
| tags | TEXT | No | '[]' | - | JSON array, e.g. ["summer","sale"] |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store
- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date
- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor
- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type

**Notes:** `tags` is a JSON-encoded array (e.g. `["summer","sale"]`). The `handle` is unique per store, not globally.

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |
| value | TEXT | No | - | - | Display value, e.g. "Small" |
| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |

**Indexes:**

- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option
- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option

---

### product_variants

Purchasable SKU-level variant of a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| sku | TEXT | Yes | NULL | - | Stock keeping unit |
| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |
| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |
| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| weight_g | INTEGER | Yes | NULL | - | Weight in grams |
| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |
| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product
- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants
- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup
- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing
- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant

**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).

---

### variant_option_values

Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |