sed -n '1,160p' database/factories/SearchSettingsFactory.php && sed -n '1,160p' database/factories/SearchQueryFactory.php && sed -n '1,120p' database/seeders/SearchSettingsSeeder.php

SearchSettingsSeeder.php

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchSettings>
 */
class SearchSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchQuery>
 */
class SearchQueryFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class SearchSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


Output:
Chunk ID: 630d83
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 283
Output:
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchSettings>
 */
class SearchSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchQuery>
 */
class SearchQueryFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class SearchSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


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

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


Output:
Chunk ID: 2f6bfb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1729
Output:
<!DOCTYPE html>
getLocale()) }}" class="dark">

        @include('partials.head')

        <flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
            <flux:sidebar.header>
                <x-app-logo :sidebar="true" href="{{ route('admin.dashboard') }}" wire:navigate />
                <flux:sidebar.collapse class="lg:hidden" />
            </flux:sidebar.header>

            <flux:sidebar.nav>
                <flux:sidebar.group :heading="__('Platform')" class="grid">
                    <flux:sidebar.item icon="home" :href="route('admin.dashboard')" :current="request()->routeIs('admin.dashboard')" wire:navigate>
                        {{ __('Dashboard') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Products')" class="grid">
                    <flux:sidebar.item icon="cube" :href="route('admin.products.index')" :current="request()->routeIs('admin.products.*')" wire:navigate>
                        {{ __('Products') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="rectangle-stack" :href="route('admin.collections.index')" :current="request()->routeIs('admin.collections.*')" wire:navigate>
                        {{ __('Collections') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="archive-box" :href="route('admin.inventory.index')" :current="request()->routeIs('admin.inventory.*')" wire:navigate>
                        {{ __('Inventory') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Operations')" class="grid">
                    <flux:sidebar.item icon="shopping-bag" :href="route('admin.orders.index')" :current="request()->routeIs('admin.orders.*')" wire:navigate>
                        {{ __('Orders') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="users" :href="route('admin.customers.index')" :current="request()->routeIs('admin.customers.*')" wire:navigate>
                        {{ __('Customers') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="tag" :href="route('admin.discounts.index')" :current="request()->routeIs('admin.discounts.*')" wire:navigate>
                        {{ __('Discounts') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Storefront')" class="grid">
                    <flux:sidebar.item icon="document-text" :href="route('admin.pages.index')" :current="request()->routeIs('admin.pages.*')" wire:navigate>
                        {{ __('Pages') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="bars-3" :href="route('admin.navigation.index')" :current="request()->routeIs('admin.navigation.*')" wire:navigate>
                        {{ __('Navigation') }}
                    </flux:sidebar.item>

                    <flux:sidebar.item icon="paint-brush" :href="route('admin.themes.index')" :current="request()->routeIs('admin.themes.*')" wire:navigate>
                        {{ __('Themes') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>

                <flux:sidebar.group :heading="__('Configuration')" class="grid">
                    <flux:sidebar.item icon="cog-6-tooth" :href="route('admin.settings.index')" :current="request()->routeIs('admin.settings.*')" wire:navigate>
                        {{ __('Settings') }}
                    </flux:sidebar.item>
                </flux:sidebar.group>
            </flux:sidebar.nav>

            <flux:spacer />

            <flux:sidebar.nav>
                <flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
                    {{ __('Repository') }}
                </flux:sidebar.item>

                <flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
                    {{ __('Documentation') }}
                </flux:sidebar.item>
            </flux:sidebar.nav>

            <x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
        </flux:sidebar>

        <flux:header class="lg:hidden">
            <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />

            <flux:spacer />

            <flux:dropdown position="top" align="end">
                <flux:profile
                    :initials="auth()->user()->initials()"
                    icon-trailing="chevron-down"
                />

                <flux:menu>
                    <flux:menu.radio.group>

                                <flux:avatar
                                    :name="auth()->user()->name"
                                    :initials="auth()->user()->initials()"
                                />

                                    <flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
                                    <flux:text class="truncate">{{ auth()->user()->email }}</flux:text>

                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <flux:menu.radio.group>
                        <flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
                            {{ __('Settings') }}
                        </flux:menu.item>
                    </flux:menu.radio.group>

                    <flux:menu.separator />

                    <form method="POST" action="{{ route('logout') }}" class="w-full">
                        @csrf
                        <flux:menu.item
                            as="button"
                            type="submit"
                            icon="arrow-right-start-on-rectangle"
                            class="w-full cursor-pointer"
                            data-test="logout-button"
                        >
                            {{ __('Log Out') }}
                        </flux:menu.item>
                    </form>
                </flux:menu>
            </flux:dropdown>
        </flux:header>

        {{ $slot }}

        @fluxScripts


Input:
*** Begin Patch
*** Update File: database/migrations/2026_05_04_030847_create_search_settings_table.php
@@
     public function up(): void
     {
         Schema::create('search_settings', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+            $table->text('synonyms_json')->default('[]');
+            $table->text('stop_words_json')->default('[]');
+            $table->timestamp('updated_at')->nullable();
         });
     }
*** Update File: database/migrations/2026_05_04_030848_create_search_queries_table.php
@@
     public function up(): void
     {
         Schema::create('search_queries', function (Blueprint $table) {
             $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->text('query');
+            $table->text('filters_json')->nullable();
+            $table->unsignedInteger('results_count')->default(0);
+            $table->timestamp('created_at')->nullable();
+
+            $table->index('store_id', 'idx_search_queries_store_id');
+            $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created');
+            $table->index(['store_id', 'query'], 'idx_search_queries_store_query');
         });
     }
*** Update File: database/migrations/2026_05_04_030848_create_products_fts_table.php
@@
-use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@
     public function up(): void
     {
-        Schema::create('products_fts', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
-        });
+        DB::statement(<<<'SQL'
+            CREATE VIRTUAL TABLE products_fts USING fts5(
+                store_id UNINDEXED,
+                product_id UNINDEXED,
+                title,
+                description,
+                vendor,
+                product_type,
+                tags,
+                tokenize = 'unicode61'
+            )
+        SQL);
+
+        DB::statement(<<<'SQL'
+            INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags)
+            SELECT store_id, id, title, COALESCE(description_html, ''), COALESCE(vendor, ''), COALESCE(product_type, ''), COALESCE(tags, '')
+            FROM products
+        SQL);
     }
@@
     public function down(): void
     {
         Schema::dropIfExists('products_fts');
     }
 };
*** Update File: app/Models/SearchSettings.php
@@
 namespace App\Models;

+use App\Models\Concerns\BelongsToStore;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;

 class SearchSettings extends Model
 {
     /** @use HasFactory<\Database\Factories\SearchSettingsFactory> */
-    use HasFactory;
+    use BelongsToStore, HasFactory;
+
+    public $incrementing = false;
+
+    public const CREATED_AT = null;
+
+    protected $primaryKey = 'store_id';
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'synonyms_json',
+        'stop_words_json',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'synonyms_json' => '[]',
+        'stop_words_json' => '[]',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'synonyms_json' => 'array',
+            'stop_words_json' => 'array',
+            'updated_at' => 'datetime',
+        ];
+    }
 }
*** Update File: app/Models/SearchQuery.php
@@
 namespace App\Models;

+use App\Models\Concerns\BelongsToStore;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;

 class SearchQuery extends Model
 {
     /** @use HasFactory<\Database\Factories\SearchQueryFactory> */
-    use HasFactory;
+    use BelongsToStore, HasFactory;
+
+    public const UPDATED_AT = null;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'query',
+        'filters_json',
+        'results_count',
+        'created_at',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'filters_json' => '{}',
+        'results_count' => 0,
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'filters_json' => 'array',
+            'results_count' => 'integer',
+            'created_at' => 'datetime',
+        ];
+    }
 }
*** Update File: database/factories/SearchSettingsFactory.php
@@
 namespace Database\Factories;

+use App\Models\Store;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@
     public function definition(): array
     {
         return [
-            //
+            'store_id' => Store::factory(),
+            'synonyms_json' => [
+                ['t-shirt', 'tee', 'tshirt'],
+                ['sneakers', 'trainers'],
+            ],
+            'stop_words_json' => ['the', 'a', 'an'],
         ];
     }
 }
*** Update File: database/factories/SearchQueryFactory.php
@@
 namespace Database\Factories;

+use App\Models\Store;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@
     public function definition(): array
     {
         return [
-            //
+            'store_id' => Store::factory(),
+            'query' => fake()->words(2, true),
+            'filters_json' => [],
+            'results_count' => fake()->numberBetween(0, 12),
+            'created_at' => now(),
         ];
     }
 }
*** Update File: database/seeders/SearchSettingsSeeder.php
@@
 namespace Database\Seeders;

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\SearchSettings;
+use App\Models\Store;
 use Illuminate\Database\Seeder;
@@
     public function run(): void
     {
-        //
+        Store::query()
+            ->get()
+            ->each(function (Store $store): void {
+                SearchSettings::withoutGlobalScopes()->updateOrCreate(
+                    ['store_id' => $store->getKey()],
+                    [
+                        'synonyms_json' => [
+                            ['t-shirt', 'tee', 'tshirt'],
+                            ['sneakers', 'trainers', 'kicks'],
+                        ],
+                        'stop_words_json' => ['the', 'a', 'an', 'is', 'are'],
+                    ],
+                );
+            });
     }
 }
*** Update File: database/seeders/DatabaseSeeder.php
@@
             UserSeeder::class,
             StoreUserSeeder::class,
             StoreSettingsSeeder::class,
+            SearchSettingsSeeder::class,
             CollectionSeeder::class,
             ProductSeeder::class,
*** End Patch


Success. Updated the following files:
M database/migrations/2026_05_04_030847_create_search_settings_table.php
M database/migrations/2026_05_04_030848_create_search_queries_table.php
M database/migrations/2026_05_04_030848_create_products_fts_table.php
M app/Models/SearchSettings.php
M app/Models/SearchQuery.php
M database/factories/SearchSettingsFactory.php
M database/factories/SearchQueryFactory.php
M database/seeders/SearchSettingsSeeder.php
M database/seeders/DatabaseSeeder.php

@@ -14,4 +14,6 @@
         Schema::create('search_settings', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+            $table->text('synonyms_json')->default('[]');
+            $table->text('stop_words_json')->default('[]');
+            $table->timestamp('updated_at')->nullable();
         });

@@ -15,3 +15,11 @@
             $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->text('query');
+            $table->text('filters_json')->nullable();
+            $table->unsignedInteger('results_count')->default(0);
+            $table->timestamp('created_at')->nullable();
+
+            $table->index('store_id', 'idx_search_queries_store_id');
+            $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created');
+            $table->index(['store_id', 'query'], 'idx_search_queries_store_query');
         });

@@ -4,2 +4,3 @@

+use App\Models\Store;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@ -19,3 +20,8 @@
         return [
-            //
+            'store_id' => Store::factory(),
+            'synonyms_json' => [
+                ['t-shirt', 'tee', 'tshirt'],
+                ['sneakers', 'trainers'],
+            ],
+            'stop_words_json' => ['the', 'a', 'an'],
         ];

@@ -4,2 +4,3 @@

+use App\Models\Store;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@ -19,3 +20,7 @@
         return [
-            //
+            'store_id' => Store::factory(),
+            'query' => fake()->words(2, true),
+            'filters_json' => [],
+            'results_count' => fake()->numberBetween(0, 12),
+            'created_at' => now(),
         ];

@@ -4,4 +4,6 @@

+use App\Models\Concerns\BelongsToStore;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;

@@ -10,3 +12,46 @@
     /** @use HasFactory<\Database\Factories\SearchSettingsFactory> */
-    use HasFactory;
+    use BelongsToStore, HasFactory;
+
+    public $incrementing = false;
+
+    public const CREATED_AT = null;
+
+    protected $primaryKey = 'store_id';
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'synonyms_json',
+        'stop_words_json',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'synonyms_json' => '[]',
+        'stop_words_json' => '[]',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'synonyms_json' => 'array',
+            'stop_words_json' => 'array',
+            'updated_at' => 'datetime',
+        ];
+    }
 }

@@ -4,3 +4,4 @@

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\SearchSettings;
+use App\Models\Store;
 use Illuminate\Database\Seeder;
@@ -14,3 +15,16 @@
     {
-        //
+        Store::query()
+            ->get()
+            ->each(function (Store $store): void {
+                SearchSettings::withoutGlobalScopes()->updateOrCreate(
+                    ['store_id' => $store->getKey()],
+                    [
+                        'synonyms_json' => [
+                            ['t-shirt', 'tee', 'tshirt'],
+                            ['sneakers', 'trainers', 'kicks'],
+                        ],
+                        'stop_words_json' => ['the', 'a', 'an', 'is', 'are'],
+                    ],
+                );
+            });
     }

@@ -3,3 +3,3 @@
 use Illuminate\Database\Migrations\Migration;
-use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -13,6 +13,20 @@
     {
-        Schema::create('products_fts', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
-        });
+        DB::statement(<<<'SQL'
+            CREATE VIRTUAL TABLE products_fts USING fts5(
+                store_id UNINDEXED,
+                product_id UNINDEXED,
+                title,
+                description,
+                vendor,
+                product_type,
+                tags,
+                tokenize = 'unicode61'
+            )
+        SQL);
+
+        DB::statement(<<<'SQL'
+            INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags)
+            SELECT store_id, id, title, COALESCE(description_html, ''), COALESCE(vendor, ''), COALESCE(product_type, ''), COALESCE(tags, '')
+            FROM products
+        SQL);
     }

@@ -20,2 +20,3 @@
             StoreSettingsSeeder::class,
+            SearchSettingsSeeder::class,
             CollectionSeeder::class,

@@ -4,4 +4,6 @@

+use App\Models\Concerns\BelongsToStore;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;

@@ -10,3 +12,44 @@
     /** @use HasFactory<\Database\Factories\SearchQueryFactory> */
-    use HasFactory;
+    use BelongsToStore, HasFactory;
+
+    public const UPDATED_AT = null;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'query',
+        'filters_json',
+        'results_count',
+        'created_at',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'filters_json' => '{}',
+        'results_count' => 0,
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'filters_json' => 'array',
+            'results_count' => 'integer',
+            'created_at' => 'datetime',
+        ];
+    }
 }


Output: {"output":"Success. Updated the following files:\nM database/migrations/2026_05_04_030847_create_search_settings_table.php\nM database/migrations/2026_05_04_030848_create_search_queries_table.php\nM database/migrations/2026_05_04_030848_create_products_fts_table.php\nM app/Models/SearchSettings.php\nM app/Models/SearchQuery.php\nM database/factories/SearchSettingsFactory.php\nM database/factories/SearchQueryFactory.php\nM database/seeders/SearchSettingsSeeder.php\nM database/seeders/DatabaseSeeder.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Services/SearchService.php
@@
 namespace App\Services;

+use App\Enums\InventoryPolicy;
+use App\Enums\ProductStatus;
+use App\Models\Collection as ProductCollection;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class SearchService
 {
     /**
-     * Create a new class instance.
+     * @param  array<string, mixed>  $filters
      */
-    public function __construct()
+    public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator
     {
-        //
+        $query = trim($query);
+        $perPage = max(1, min(50, $perPage));
+
+        if ($query === '') {
+            return $this->browse($store, $filters, $perPage, $sort);
+        }
+
+        $paginator = $this->baseSearchQuery($store, $query, $filters)
+            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, true))
+            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());
+
+        $paginator->setCollection($this->hydrateProducts(
+            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
+        ));
+
+        $this->log($store, $query, $filters, $paginator->total());
+
+        return $paginator;
+    }
+
+    public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection
+    {
+        $prefix = trim($prefix);
+        $limit = max(1, min(10, $limit));
+
+        if ($prefix === '') {
+            return collect();
+        }
+
+        $ids = $this->baseSearchQuery($store, $prefix, [])
+            ->orderByRaw('products_fts.rank')
+            ->limit($limit)
+            ->pluck('products.id')
+            ->map(fn (mixed $id): int => (int) $id)
+            ->all();
+
+        return $this->hydrateProducts($ids);
+    }
+
+    public function suggestions(Store $store, string $prefix, int $limit = 5): Collection
+    {
+        $productLimit = max(1, min(10, $limit));
+
+        $products = $this->autocomplete($store, $prefix, $productLimit)
+            ->map(function (Product $product): array {
+                $variant = $product->variants->first();
+
+                return [
+                    'type' => 'product',
+                    'title' => $product->title,
+                    'handle' => $product->handle,
+                    'image_url' => $product->media->first()?->storage_key,
+                    'price_amount' => $variant?->price_amount,
+                    'currency' => $variant?->currency ?? $product->store?->default_currency,
+                ];
+            });
+
+        $remaining = max(0, $productLimit - $products->count());
+
+        $collections = $remaining > 0
+            ? ProductCollection::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->where('status', 'active')
+                ->where('title', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], trim($prefix)).'%')
+                ->oldest('title')
+                ->limit($remaining)
+                ->get()
+                ->map(fn (ProductCollection $collection): array => [
+                    'type' => 'collection',
+                    'title' => $collection->title,
+                    'handle' => $collection->handle,
+                    'image_url' => null,
+                ])
+            : collect();
+
+        return $products->concat($collections)->values();
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     * @return array{vendors: list<array{value: string, count: int}>, tags: list<array{value: string, count: int}>, price_range: array{min: int|null, max: int|null}}
+     */
+    public function facets(Store $store, string $query, array $filters = []): array
+    {
+        $ids = trim($query) === ''
+            ? $this->activeProductQuery($store, $filters)->limit(1000)->pluck('products.id')->all()
+            : $this->baseSearchQuery($store, $query, $filters)->limit(1000)->pluck('products.id')->all();
+
+        $ids = collect($ids)->map(fn (mixed $id): int => (int) $id)->all();
+
+        if ($ids === []) {
+            return [
+                'vendors' => [],
+                'tags' => [],
+                'price_range' => ['min' => null, 'max' => null],
+            ];
+        }
+
+        $vendors = Product::withoutGlobalScopes()
+            ->whereIn('id', $ids)
+            ->whereNotNull('vendor')
+            ->selectRaw('vendor as value, COUNT(*) as count')
+            ->groupBy('vendor')
+            ->orderBy('vendor')
+            ->get()
+            ->map(fn (Product $product): array => [
+                'value' => (string) $product->value,
+                'count' => (int) $product->count,
+            ])
+            ->all();
+
+        $tags = Product::withoutGlobalScopes()
+            ->whereIn('id', $ids)
+            ->get(['tags'])
+            ->flatMap(fn (Product $product): array => $product->tags ?? [])
+            ->filter()
+            ->countBy()
+            ->sortKeys()
+            ->map(fn (int $count, string $tag): array => [
+                'value' => $tag,
+                'count' => $count,
+            ])
+            ->values()
+            ->all();
+
+        $priceRange = ProductVariant::withoutGlobalScopes()
+            ->whereIn('product_id', $ids)
+            ->selectRaw('MIN(price_amount) as min_price, MAX(price_amount) as max_price')
+            ->first();
+
+        return [
+            'vendors' => $vendors,
+            'tags' => $tags,
+            'price_range' => [
+                'min' => $priceRange?->min_price === null ? null : (int) $priceRange->min_price,
+                'max' => $priceRange?->max_price === null ? null : (int) $priceRange->max_price,
+            ],
+        ];
+    }
+
+    public function syncProduct(Product $product): void
+    {
+        DB::table('products_fts')
+            ->where('product_id', $product->getKey())
+            ->delete();
+
+        DB::table('products_fts')->insert([
+            'store_id' => $product->store_id,
+            'product_id' => $product->getKey(),
+            'title' => $product->title,
+            'description' => trim(strip_tags((string) $product->description_html)),
+            'vendor' => (string) $product->vendor,
+            'product_type' => (string) $product->product_type,
+            'tags' => collect(Arr::wrap($product->tags))->filter()->implode(' '),
+        ]);
+    }
+
+    public function removeProduct(int $productId): void
+    {
+        DB::table('products_fts')
+            ->where('product_id', $productId)
+            ->delete();
+    }
+
+    public function reindex(Store $store): int
+    {
+        DB::table('products_fts')
+            ->where('store_id', $store->getKey())
+            ->delete();
+
+        $count = 0;
+
+        Product::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->orderBy('id')
+            ->chunkById(100, function (Collection $products) use (&$count): void {
+                $products->each(function (Product $product) use (&$count): void {
+                    $this->syncProduct($product);
+                    $count++;
+                });
+            });
+
+        return $count;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function browse(Store $store, array $filters, int $perPage, string $sort): LengthAwarePaginator
+    {
+        $paginator = $this->activeProductQuery($store, $filters)
+            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, false))
+            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());
+
+        $paginator->setCollection($this->hydrateProducts(
+            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
+        ));
+
+        return $paginator;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function baseSearchQuery(Store $store, string $query, array $filters): QueryBuilder
+    {
+        $match = $this->toFtsQuery($query);
+
+        if ($match === '') {
+            return $this->activeProductQuery($store, $filters);
+        }
+
+        return $this->activeProductQuery($store, $filters)
+            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
+            ->where('products_fts.store_id', $store->getKey())
+            ->whereRaw('products_fts MATCH ?', [$match]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function activeProductQuery(Store $store, array $filters): QueryBuilder
+    {
+        $builder = DB::table('products')
+            ->select('products.id')
+            ->where('products.store_id', $store->getKey())
+            ->where('products.status', ProductStatus::Active->value)
+            ->whereNotNull('products.published_at');
+
+        $this->applyFilters($builder, $filters);
+
+        return $builder;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function applyFilters(QueryBuilder $builder, array $filters): void
+    {
+        if (isset($filters['collection_id'])) {
+            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('collection_products')
+                    ->whereColumn('collection_products.product_id', 'products.id')
+                    ->where('collection_products.collection_id', (int) $filters['collection_id']);
+            });
+        }
+
+        if (filled($filters['vendor'] ?? null)) {
+            $builder->where('products.vendor', (string) $filters['vendor']);
+        }
+
+        foreach (Arr::wrap($filters['tags'] ?? []) as $tag) {
+            if (filled($tag)) {
+                $builder->where('products.tags', 'like', '%"'.str_replace(['%', '_'], ['\%', '\_'], (string) $tag).'"%');
+            }
+        }
+
+        if (isset($filters['price_min']) || isset($filters['price_max'])) {
+            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('product_variants')
+                    ->whereColumn('product_variants.product_id', 'products.id');
+
+                if (isset($filters['price_min'])) {
+                    $query->where('product_variants.price_amount', '>=', (int) $filters['price_min']);
+                }
+
+                if (isset($filters['price_max'])) {
+                    $query->where('product_variants.price_amount', '<=', (int) $filters['price_max']);
+                }
+            });
+        }
+
+        if (filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOL)) {
+            $builder->whereExists(function (QueryBuilder $query): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('product_variants')
+                    ->join('inventory_items', 'inventory_items.variant_id', '=', 'product_variants.id')
+                    ->whereColumn('product_variants.product_id', 'products.id')
+                    ->where(function (QueryBuilder $query): void {
+                        $query
+                            ->whereRaw('inventory_items.quantity_on_hand > inventory_items.quantity_reserved')
+                            ->orWhere('inventory_items.policy', InventoryPolicy::Continue->value);
+                    });
+            });
+        }
+    }
+
+    private function applySort(QueryBuilder $builder, string $sort, bool $hasRank): void
+    {
+        match ($sort) {
+            'price_asc' => $builder
+                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC')
+                ->orderBy('products.id'),
+            'price_desc' => $builder
+                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC')
+                ->orderBy('products.id'),
+            'newest' => $builder
+                ->orderByDesc('products.published_at')
+                ->orderByDesc('products.id'),
+            'best_selling' => $builder
+                ->orderByRaw('(SELECT COALESCE(SUM(order_lines.quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC')
+                ->orderByDesc('products.published_at')
+                ->orderByDesc('products.id'),
+            default => $hasRank
+                ? $builder->orderByRaw('products_fts.rank')
+                : $builder->orderByDesc('products.published_at')->orderByDesc('products.id'),
+        };
+    }
+
+    /**
+     * @param  list<int>  $ids
+     * @return Collection<int, Product>
+     */
+    private function hydrateProducts(array $ids): Collection
+    {
+        if ($ids === []) {
+            return collect();
+        }
+
+        $products = Product::withoutGlobalScopes()
+            ->with(['store', 'variants.inventoryItem', 'media'])
+            ->withCount('variants')
+            ->whereIn('id', $ids)
+            ->get()
+            ->keyBy('id');
+
+        return collect($ids)
+            ->map(fn (int $id): ?Product => $products->get($id))
+            ->filter()
+            ->values();
+    }
+
+    private function toFtsQuery(string $query): string
+    {
+        $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+
+        return collect($tokens)
+            ->take(8)
+            ->map(fn (string $token, int $index) => $index === count($tokens) - 1 ? "{$token}*" : $token)
+            ->implode(' ');
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function log(Store $store, string $query, array $filters, int $resultsCount): void
+    {
+        SearchQuery::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'query' => mb_substr($query, 0, 200),
+            'filters_json' => $filters,
+            'results_count' => $resultsCount,
+            'created_at' => now(),
+        ]);
     }
 }
*** End Patch


Success. Updated the following files:
M app/Services/SearchService.php

@@ -4,2 +4,16 @@

+use App\Enums\InventoryPolicy;
+use App\Enums\ProductStatus;
+use App\Models\Collection as ProductCollection;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\SearchQuery;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class SearchService
@@ -7,7 +21,365 @@
     /**
-     * Create a new class instance.
+     * @param  array<string, mixed>  $filters
+     */
+    public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator
+    {
+        $query = trim($query);
+        $perPage = max(1, min(50, $perPage));
+
+        if ($query === '') {
+            return $this->browse($store, $filters, $perPage, $sort);
+        }
+
+        $paginator = $this->baseSearchQuery($store, $query, $filters)
+            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, true))
+            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());
+
+        $paginator->setCollection($this->hydrateProducts(
+            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
+        ));
+
+        $this->log($store, $query, $filters, $paginator->total());
+
+        return $paginator;
+    }
+
+    public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection
+    {
+        $prefix = trim($prefix);
+        $limit = max(1, min(10, $limit));
+
+        if ($prefix === '') {
+            return collect();
+        }
+
+        $ids = $this->baseSearchQuery($store, $prefix, [])
+            ->orderByRaw('products_fts.rank')
+            ->limit($limit)
+            ->pluck('products.id')
+            ->map(fn (mixed $id): int => (int) $id)
+            ->all();
+
+        return $this->hydrateProducts($ids);
+    }
+
+    public function suggestions(Store $store, string $prefix, int $limit = 5): Collection
+    {
+        $productLimit = max(1, min(10, $limit));
+
+        $products = $this->autocomplete($store, $prefix, $productLimit)
+            ->map(function (Product $product): array {
+                $variant = $product->variants->first();
+
+                return [
+                    'type' => 'product',
+                    'title' => $product->title,
+                    'handle' => $product->handle,
+                    'image_url' => $product->media->first()?->storage_key,
+                    'price_amount' => $variant?->price_amount,
+                    'currency' => $variant?->currency ?? $product->store?->default_currency,
+                ];
+            });
+
+        $remaining = max(0, $productLimit - $products->count());
+
+        $collections = $remaining > 0
+            ? ProductCollection::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->where('status', 'active')
+                ->where('title', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], trim($prefix)).'%')
+                ->oldest('title')
+                ->limit($remaining)
+                ->get()
+                ->map(fn (ProductCollection $collection): array => [
+                    'type' => 'collection',
+                    'title' => $collection->title,
+                    'handle' => $collection->handle,
+                    'image_url' => null,
+                ])
+            : collect();
+
+        return $products->concat($collections)->values();
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     * @return array{vendors: list<array{value: string, count: int}>, tags: list<array{value: string, count: int}>, price_range: array{min: int|null, max: int|null}}
+     */
+    public function facets(Store $store, string $query, array $filters = []): array
+    {
+        $ids = trim($query) === ''
+            ? $this->activeProductQuery($store, $filters)->limit(1000)->pluck('products.id')->all()
+            : $this->baseSearchQuery($store, $query, $filters)->limit(1000)->pluck('products.id')->all();
+
+        $ids = collect($ids)->map(fn (mixed $id): int => (int) $id)->all();
+
+        if ($ids === []) {
+            return [
+                'vendors' => [],
+                'tags' => [],
+                'price_range' => ['min' => null, 'max' => null],
+            ];
+        }
+
+        $vendors = Product::withoutGlobalScopes()
+            ->whereIn('id', $ids)
+            ->whereNotNull('vendor')
+            ->selectRaw('vendor as value, COUNT(*) as count')
+            ->groupBy('vendor')
+            ->orderBy('vendor')
+            ->get()
+            ->map(fn (Product $product): array => [
+                'value' => (string) $product->value,
+                'count' => (int) $product->count,
+            ])
+            ->all();
+
+        $tags = Product::withoutGlobalScopes()
+            ->whereIn('id', $ids)
+            ->get(['tags'])
+            ->flatMap(fn (Product $product): array => $product->tags ?? [])
+            ->filter()
+            ->countBy()
+            ->sortKeys()
+            ->map(fn (int $count, string $tag): array => [
+                'value' => $tag,
+                'count' => $count,
+            ])
+            ->values()
+            ->all();
+
+        $priceRange = ProductVariant::withoutGlobalScopes()
+            ->whereIn('product_id', $ids)
+            ->selectRaw('MIN(price_amount) as min_price, MAX(price_amount) as max_price')
+            ->first();
+
+        return [
+            'vendors' => $vendors,
+            'tags' => $tags,
+            'price_range' => [
+                'min' => $priceRange?->min_price === null ? null : (int) $priceRange->min_price,
+                'max' => $priceRange?->max_price === null ? null : (int) $priceRange->max_price,
+            ],
+        ];
+    }
+
+    public function syncProduct(Product $product): void
+    {
+        DB::table('products_fts')
+            ->where('product_id', $product->getKey())
+            ->delete();
+
+        DB::table('products_fts')->insert([
+            'store_id' => $product->store_id,
+            'product_id' => $product->getKey(),
+            'title' => $product->title,
+            'description' => trim(strip_tags((string) $product->description_html)),
+            'vendor' => (string) $product->vendor,
+            'product_type' => (string) $product->product_type,
+            'tags' => collect(Arr::wrap($product->tags))->filter()->implode(' '),
+        ]);
+    }
+
+    public function removeProduct(int $productId): void
+    {
+        DB::table('products_fts')
+            ->where('product_id', $productId)
+            ->delete();
+    }
+
+    public function reindex(Store $store): int
+    {
+        DB::table('products_fts')
+            ->where('store_id', $store->getKey())
+            ->delete();
+
+        $count = 0;
+
+        Product::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->orderBy('id')
+            ->chunkById(100, function (Collection $products) use (&$count): void {
+                $products->each(function (Product $product) use (&$count): void {
+                    $this->syncProduct($product);
+                    $count++;
+                });
+            });
+
+        return $count;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function browse(Store $store, array $filters, int $perPage, string $sort): LengthAwarePaginator
+    {
+        $paginator = $this->activeProductQuery($store, $filters)
+            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, false))
+            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());
+
+        $paginator->setCollection($this->hydrateProducts(
+            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
+        ));
+
+        return $paginator;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
      */
-    public function __construct()
+    private function baseSearchQuery(Store $store, string $query, array $filters): QueryBuilder
     {
-        //
+        $match = $this->toFtsQuery($query);
+
+        if ($match === '') {
+            return $this->activeProductQuery($store, $filters);
+        }
+
+        return $this->activeProductQuery($store, $filters)
+            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
+            ->where('products_fts.store_id', $store->getKey())
+            ->whereRaw('products_fts MATCH ?', [$match]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function activeProductQuery(Store $store, array $filters): QueryBuilder
+    {
+        $builder = DB::table('products')
+            ->select('products.id')
+            ->where('products.store_id', $store->getKey())
+            ->where('products.status', ProductStatus::Active->value)
+            ->whereNotNull('products.published_at');
+
+        $this->applyFilters($builder, $filters);
+
+        return $builder;
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function applyFilters(QueryBuilder $builder, array $filters): void
+    {
+        if (isset($filters['collection_id'])) {
+            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('collection_products')
+                    ->whereColumn('collection_products.product_id', 'products.id')
+                    ->where('collection_products.collection_id', (int) $filters['collection_id']);
+            });
+        }
+
+        if (filled($filters['vendor'] ?? null)) {
+            $builder->where('products.vendor', (string) $filters['vendor']);
+        }
+
+        foreach (Arr::wrap($filters['tags'] ?? []) as $tag) {
+            if (filled($tag)) {
+                $builder->where('products.tags', 'like', '%"'.str_replace(['%', '_'], ['\%', '\_'], (string) $tag).'"%');
+            }
+        }
+
+        if (isset($filters['price_min']) || isset($filters['price_max'])) {
+            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('product_variants')
+                    ->whereColumn('product_variants.product_id', 'products.id');
+
+                if (isset($filters['price_min'])) {
+                    $query->where('product_variants.price_amount', '>=', (int) $filters['price_min']);
+                }
+
+                if (isset($filters['price_max'])) {
+                    $query->where('product_variants.price_amount', '<=', (int) $filters['price_max']);
+                }
+            });
+        }
+
+        if (filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOL)) {
+            $builder->whereExists(function (QueryBuilder $query): void {
+                $query
+                    ->selectRaw('1')
+                    ->from('product_variants')
+                    ->join('inventory_items', 'inventory_items.variant_id', '=', 'product_variants.id')
+                    ->whereColumn('product_variants.product_id', 'products.id')
+                    ->where(function (QueryBuilder $query): void {
+                        $query
+                            ->whereRaw('inventory_items.quantity_on_hand > inventory_items.quantity_reserved')
+                            ->orWhere('inventory_items.policy', InventoryPolicy::Continue->value);
+                    });
+            });
+        }
+    }
+
+    private function applySort(QueryBuilder $builder, string $sort, bool $hasRank): void
+    {
+        match ($sort) {
+            'price_asc' => $builder
+                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC')
+                ->orderBy('products.id'),
+            'price_desc' => $builder
+                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC')
+                ->orderBy('products.id'),
+            'newest' => $builder
+                ->orderByDesc('products.published_at')
+                ->orderByDesc('products.id'),
+            'best_selling' => $builder
+                ->orderByRaw('(SELECT COALESCE(SUM(order_lines.quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC')
+                ->orderByDesc('products.published_at')
+                ->orderByDesc('products.id'),
+            default => $hasRank
+                ? $builder->orderByRaw('products_fts.rank')
+                : $builder->orderByDesc('products.published_at')->orderByDesc('products.id'),
+        };
+    }
+
+    /**
+     * @param  list<int>  $ids
+     * @return Collection<int, Product>
+     */
+    private function hydrateProducts(array $ids): Collection
+    {
+        if ($ids === []) {
+            return collect();
+        }
+
+        $products = Product::withoutGlobalScopes()
+            ->with(['store', 'variants.inventoryItem', 'media'])
+            ->withCount('variants')
+            ->whereIn('id', $ids)
+            ->get()
+            ->keyBy('id');
+
+        return collect($ids)
+            ->map(fn (int $id): ?Product => $products->get($id))
+            ->filter()
+            ->values();
+    }
+
+    private function toFtsQuery(string $query): string
+    {
+        $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+
+        return collect($tokens)
+            ->take(8)
+            ->map(fn (string $token, int $index) => $index === count($tokens) - 1 ? "{$token}*" : $token)
+            ->implode(' ');
+    }
+
+    /**
+     * @param  array<string, mixed>  $filters
+     */
+    private function log(Store $store, string $query, array $filters, int $resultsCount): void
+    {
+        SearchQuery::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'query' => mb_substr($query, 0, 200),
+            'filters_json' => $filters,
+            'results_count' => $resultsCount,
+            'created_at' => now(),
+        ]);
     }


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


Input:
*** Begin Patch
*** Update File: app/Observers/ProductObserver.php
@@
 use App\Models\Product;
+use App\Services\SearchService;

 class ProductObserver
 {
-    /**
-     * Handle the Product "created" event.
-     */
     public function created(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "updated" event.
-     */
     public function updated(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "deleted" event.
-     */
     public function deleted(Product $product): void
     {
-        //
-    }
-
-    /**
-     * Handle the Product "restored" event.
-     */
-    public function restored(Product $product): void
-    {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

-    /**
-     * Handle the Product "force deleted" event.
-     */
     public function forceDeleted(Product $product): void
     {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }
 }
*** Update File: app/Providers/AppServiceProvider.php
@@
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Http\Middleware\ResolveStore;
+use App\Models\Product;
 use App\Models\Store;
+use App\Observers\ProductObserver;
 use App\Services\NavigationService;
 use App\Services\Payments\MockPaymentProvider;
+use App\Services\SearchService;
 use App\Services\ThemeSettingsService;
@@
         $this->app->singleton(ThemeSettingsService::class);
         $this->app->singleton(NavigationService::class);
+        $this->app->singleton(SearchService::class);
@@
         RateLimiter::for('checkout', function (Request $request): Limit {
             $sessionId = $request->hasSession() ? $request->session()->getId() : null;

             return Limit::perMinute(10)->by($sessionId ?: $request->ip());
         });

+        RateLimiter::for('search', function (Request $request): Limit {
+            return Limit::perMinute(30)->by($request->ip());
+        });
+
+        RateLimiter::for('analytics', function (Request $request): Limit {
+            return Limit::perMinute(60)->by($request->ip());
+        });
+
+        Product::observe(ProductObserver::class);
+
         Authenticate::redirectUsing(function (Request $request): string {
*** Update File: routes/api.php
@@
 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('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');
+        });
*** Update File: routes/web.php
@@
 use App\Livewire\Admin\Pages\Form as AdminPageForm;
 use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
 use App\Livewire\Admin\Products\Form as AdminProductForm;
 use App\Livewire\Admin\Products\Index as AdminProductsIndex;
+use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
 use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
@@
     Route::livewire('settings', AdminSettingsIndex::class)->name('settings.index');
     Route::livewire('settings/shipping', AdminSettingsShipping::class)->name('settings.shipping');
     Route::livewire('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes');
+    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');
*** Update File: resources/views/layouts/app/sidebar.blade.php
@@
                 <flux:sidebar.group :heading="__('Configuration')" class="grid">
                     <flux:sidebar.item icon="cog-6-tooth" :href="route('admin.settings.index')" :current="request()->routeIs('admin.settings.*')" wire:navigate>
                         {{ __('Settings') }}
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
+                        {{ __('Search') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>
*** End Patch


Success. Updated the following files:
M app/Observers/ProductObserver.php
M app/Providers/AppServiceProvider.php
M routes/api.php
M routes/web.php
M resources/views/layouts/app/sidebar.blade.php

@@ -9,2 +9,3 @@
 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;
@@ -23,2 +24,7 @@

+        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:checkout')->group(function (): void {

@@ -9,5 +9,8 @@
 use App\Http\Middleware\ResolveStore;
+use App\Models\Product;
 use App\Models\Store;
+use App\Observers\ProductObserver;
 use App\Services\NavigationService;
 use App\Services\Payments\MockPaymentProvider;
+use App\Services\SearchService;
 use App\Services\ThemeSettingsService;
@@ -42,2 +45,3 @@
         $this->app->singleton(NavigationService::class);
+        $this->app->singleton(SearchService::class);
     }
@@ -85,2 +89,12 @@

+        RateLimiter::for('search', function (Request $request): Limit {
+            return Limit::perMinute(30)->by($request->ip());
+        });
+
+        RateLimiter::for('analytics', function (Request $request): Limit {
+            return Limit::perMinute(60)->by($request->ip());
+        });
+
+        Product::observe(ProductObserver::class);
+
         Authenticate::redirectUsing(function (Request $request): string {

@@ -19,2 +19,3 @@
 use App\Livewire\Admin\Products\Index as AdminProductsIndex;
+use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
 use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
@@ -87,2 +88,3 @@
     Route::livewire('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes');
+    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');

@@ -5,2 +5,3 @@
 use App\Models\Product;
+use App\Services\SearchService;

@@ -8,40 +9,20 @@
 {
-    /**
-     * Handle the Product "created" event.
-     */
     public function created(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "updated" event.
-     */
     public function updated(Product $product): void
     {
-        //
+        app(SearchService::class)->syncProduct($product);
     }

-    /**
-     * Handle the Product "deleted" event.
-     */
     public function deleted(Product $product): void
     {
-        //
-    }
-
-    /**
-     * Handle the Product "restored" event.
-     */
-    public function restored(Product $product): void
-    {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

-    /**
-     * Handle the Product "force deleted" event.
-     */
     public function forceDeleted(Product $product): void
     {
-        //
+        app(SearchService::class)->removeProduct($product->getKey());
     }

@@ -65,2 +65,6 @@
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="magnifying-glass" :href="route('admin.search.settings')" :current="request()->routeIs('admin.search.*')" wire:navigate>
+                        {{ __('Search') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>


Output: {"output":"Success. Updated the following files:\nM app/Observers/ProductObserver.php\nM app/Providers/AppServiceProvider.php\nM routes/api.php\nM routes/web.php\nM resources/views/layouts/app/sidebar.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
@@
 namespace App\Http\Requests\Api\Storefront\V1;

 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;

 class SearchProductsRequest extends FormRequest
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }

     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }
*** Update File: app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
@@
 class SuggestProductsRequest extends FormRequest
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }

     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];
     }
 }
*** Update File: app/Http/Resources/Storefront/V1/SearchProductResource.php
@@
 namespace App\Http\Resources\Storefront\V1;

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;

 class SearchProductResource extends JsonResource
 {
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }
 }
*** Update File: app/Http/Controllers/Api/Storefront/V1/SearchController.php
@@
 namespace App\Http\Controllers\Api\Storefront\V1;

 use App\Http\Controllers\Controller;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

 class SearchController extends Controller
 {
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+
+        $results = $search->search(
+            $this->currentStore(),
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($this->currentStore(), $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }
*** End Patch


Output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/V1/SearchController.php:
namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;

class SearchController extends Controller
{
}


sed -n '1,160p' app/Http/Controllers/Api/Storefront/V1/SearchController.php


sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php && sed -n '1,120p' app/Http/Resources/Storefront/V1/SearchProductResource.php


sed -n '1,160p' app/Http/Controllers/Api/Storefront/V1/SearchController.php

<?php

namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    //
}


Output:
Chunk ID: 2d7c9a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 45
Output:
<?php

namespace App\Http\Controllers\Api\Storefront\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    //
}


sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php && sed -n '1,120p' app/Http/Resources/Storefront/V1/SearchProductResource.php

SearchProductResource.php

<?php

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class SearchProductsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SearchProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


Output:
Chunk ID: 07bedd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 243
Output:
<?php

namespace App\Http\Requests\Api\Storefront\V1;

use Illuminate\Foundation\Http\FormRequest;

class SearchProductsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SearchProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


Input:
*** Begin Patch
*** Update File: app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
@@
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
@@
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }
*** Update File: app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
@@
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
      */
     public function rules(): array
     {
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];
     }
 }
*** Update File: app/Http/Resources/Storefront/V1/SearchProductResource.php
@@
 namespace App\Http\Resources\Storefront\V1;

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }
 }
*** Update File: app/Http/Controllers/Api/Storefront/V1/SearchController.php
@@
 use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

 class SearchController extends Controller
 {
-    //
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+        $store = $this->currentStore();
+
+        $results = $search->search(
+            $store,
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($store, $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php
M app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php
M app/Http/Resources/Storefront/V1/SearchProductResource.php
M app/Http/Controllers/Api/Storefront/V1/SearchController.php

@@ -5,3 +5,8 @@
 use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
+use App\Http\Requests\Api\Storefront\V1\SearchProductsRequest;
+use App\Http\Requests\Api\Storefront\V1\SuggestProductsRequest;
+use App\Http\Resources\Storefront\V1\SearchProductResource;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;

@@ -9,3 +14,49 @@
 {
-    //
+    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $filters = $validated['filters'] ?? [];
+        $query = trim((string) $validated['q']);
+        $store = $this->currentStore();
+
+        $results = $search->search(
+            $store,
+            $query,
+            $filters,
+            (int) ($validated['per_page'] ?? 24),
+            (string) ($validated['sort'] ?? 'relevance'),
+        );
+
+        return response()->json([
+            'query' => $query,
+            'results' => SearchProductResource::collection($results->getCollection())->resolve($request),
+            'facets' => $search->facets($store, $query, $filters),
+            'pagination' => [
+                'current_page' => $results->currentPage(),
+                'per_page' => $results->perPage(),
+                'total' => $results->total(),
+                'last_page' => $results->lastPage(),
+            ],
+        ]);
+    }
+
+    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
+    {
+        $validated = $request->validated();
+        $query = trim((string) $validated['q']);
+
+        return response()->json([
+            'query' => $query,
+            'suggestions' => $search->suggestions($this->currentStore(), $query, (int) ($validated['limit'] ?? 5)),
+        ]);
+    }
+
+    private function currentStore(): Store
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        return $store;
+    }
 }

@@ -8,8 +8,5 @@
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@ -17,4 +14,2 @@
     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
@@ -24,3 +19,4 @@
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:100'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:10'],
         ];

@@ -5,2 +5,3 @@
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;

@@ -8,8 +9,5 @@
 {
-    /**
-     * Determine if the user is authorized to make this request.
-     */
     public function authorize(): bool
     {
-        return false;
+        return true;
     }
@@ -17,4 +15,2 @@
     /**
-     * Get the validation rules that apply to the request.
-     *
      * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
@@ -24,5 +20,31 @@
         return [
-            //
+            'q' => ['required', 'string', 'min:1', 'max:200'],
+            'filters' => ['nullable', 'array'],
+            'filters.collection_id' => ['nullable', 'integer', 'min:1'],
+            'filters.price_min' => ['nullable', 'integer', 'min:0'],
+            'filters.price_max' => ['nullable', 'integer', 'min:0', 'gte:filters.price_min'],
+            'filters.in_stock' => ['nullable', 'boolean'],
+            'filters.tags' => ['nullable', 'array', 'max:20'],
+            'filters.tags.*' => ['string', 'max:100'],
+            'filters.vendor' => ['nullable', 'string', 'max:255'],
+            'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
         ];
     }
+
+    protected function prepareForValidation(): void
+    {
+        if (! is_string($this->input('filters'))) {
+            return;
+        }
+
+        $decoded = json_decode((string) $this->input('filters'), true);
+
+        $this->merge([
+            'filters' => json_last_error() === JSON_ERROR_NONE && is_array($decoded)
+                ? $decoded
+                : '__invalid_filters__',
+        ]);
+    }
 }

@@ -4,2 +4,4 @@

+use App\Enums\InventoryPolicy;
+use App\Models\ProductVariant;
 use Illuminate\Http\Request;
@@ -16,3 +18,31 @@
     {
-        return parent::toArray($request);
+        $variant = $this->relationLoaded('variants')
+            ? $this->variants->first()
+            : null;
+
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'handle' => $this->handle,
+            'vendor' => $this->vendor,
+            'product_type' => $this->product_type,
+            'price_amount' => $variant?->price_amount,
+            'compare_at_amount' => $variant?->compare_at_amount,
+            'currency' => $variant?->currency ?? $this->store?->default_currency,
+            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,
+            'in_stock' => $variant instanceof ProductVariant ? $this->variantIsInStock($variant) : false,
+            'tags' => $this->tags ?? [],
+        ];
+    }
+
+    private function variantIsInStock(ProductVariant $variant): bool
+    {
+        $inventory = $variant->inventoryItem;
+
+        if ($inventory === null) {
+            return false;
+        }
+
+        return $inventory->availableQuantity() > 0
+            || $inventory->policy === InventoryPolicy::Continue;
     }


Output: {"output":"Success. Updated the following files:\nM app/Http/Requests/Api/Storefront/V1/SearchProductsRequest.php\nM app/Http/Requests/Api/Storefront/V1/SuggestProductsRequest.php\nM app/Http/Resources/Storefront/V1/SearchProductResource.php\nM app/Http/Controllers/Api/Storefront/V1/SearchController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


sed -n '1,260p' app/Livewire/Storefront/Collections/Show.php && sed -n '1,260p' resources/views/livewire/storefront/collections/show.blade.php


sed -n '1,240p' app/Livewire/Admin/Settings/Shipping.php


sed -n '1,260p' app/Livewire/Storefront/Collections/Show.php && sed -n '1,260p' resources/views/livewire/storefront/collections/show.blade.php

show.blade.php

<?php

namespace App\Livewire\Storefront\Collections;

use App\Models\Collection;
use App\Models\ProductVariant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection as SupportCollection;
use Livewire\Component;
use Livewire\WithPagination;

class Show extends Component
{
    use WithPagination;

    public string $handle;

    public string $sort = 'featured';

    public bool $inStock = false;

    public ?int $minPrice = null;

    public ?int $maxPrice = null;

    /**
     * @var array<int, string>
     */
    public array $types = [];

    /**
     * @var array<int, string>
     */
    public array $vendors = [];

    public function mount(string $handle): void
    {
        $this->handle = $handle;
    }

    public function updated(): void
    {
        $this->resetPage();
    }

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->minPrice = null;
        $this->maxPrice = null;
        $this->types = [];
        $this->vendors = [];
        $this->resetPage();
    }

    public function collection(): Collection
    {
        return Collection::query()
            ->where('handle', $this->handle)
            ->where('status', 'active')
            ->firstOrFail();
    }

    public function products(): LengthAwarePaginator
    {
        $collection = $this->collection();

        return $collection->products()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->when($this->inStock, function (Builder $query): void {
                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
                    $query
                        ->where('policy', 'continue')
                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
                });
            })
            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
            ->when($this->minPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
            })
            ->when($this->maxPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
            })
            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
            ->paginate(12);
    }

    public function productTypes(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function productVendors(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('vendor')
            ->distinct()
            ->orderBy('vendor')
            ->pluck('vendor');
    }

    public function render(): mixed
    {
        $collection = $this->collection();

        return view('livewire.storefront.collections.show', [
            'collection' => $collection,
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
            'productVendors' => $this->productVendors(),
        ])->layout('layouts.storefront', [
            'title' => $collection->title,
        ]);
    }
}

    <x-storefront.breadcrumbs :items="[
        ['label' => 'Collections', 'url' => route('collections.index')],
        ['label' => $collection->title],
    ]" />

{{ $collection->title }}

        @if ($collection->description_html)

{!! $collection->description_html !!}

        @endif

Filters

                <button wire:click="clearFilters" class="text-sm font-medium text-blue-700 hover:underline dark:text-blue-300">Clear all</button>

                <flux:checkbox wire:model.live="inStock" label="In stock" />

Price

                        <flux:input wire:model.live.debounce.500ms="minPrice" type="number" min="0" placeholder="Min" aria-label="Minimum price" />
                        <flux:input wire:model.live.debounce.500ms="maxPrice" type="number" min="0" placeholder="Max" aria-label="Maximum price" />

Product type

                        @foreach ($productTypes as $type)
                            <flux:checkbox wire:model.live="types" value="{{ $type }}" :label="$type" wire:key="type-filter-{{ $type }}" />
                        @endforeach

Vendor

                        @foreach ($productVendors as $vendor)
                            <flux:checkbox wire:model.live="vendors" value="{{ $vendor }}" :label="$vendor" wire:key="vendor-filter-{{ $vendor }}" />
                        @endforeach

{{ $products->total() }} products

                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
                    <flux:select.option value="featured">Featured</flux:select.option>
                    <flux:select.option value="price_asc">Price: Low to High</flux:select.option>
                    <flux:select.option value="price_desc">Price: High to Low</flux:select.option>
                    <flux:select.option value="newest">Newest</flux:select.option>
                </flux:select>

            @if ($products->isEmpty())

                        <flux:icon name="magnifying-glass" class="size-10 text-zinc-400" />

No products found

Try adjusting your filters or browse another collection.

                        <flux:button wire:click="clearFilters" variant="ghost">Clear filters</flux:button>

            @else

                    @foreach ($products as $product)
                        <x-storefront.product-card :product="$product" wire:key="collection-product-{{ $product->getKey() }}" />
                    @endforeach

                {{ $products->links() }}
            @endif


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

namespace App\Livewire\Storefront\Collections;

use App\Models\Collection;
use App\Models\ProductVariant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection as SupportCollection;
use Livewire\Component;
use Livewire\WithPagination;

class Show extends Component
{
    use WithPagination;

    public string $handle;

    public string $sort = 'featured';

    public bool $inStock = false;

    public ?int $minPrice = null;

    public ?int $maxPrice = null;

    /**
     * @var array<int, string>
     */
    public array $types = [];

    /**
     * @var array<int, string>
     */
    public array $vendors = [];

    public function mount(string $handle): void
    {
        $this->handle = $handle;
    }

    public function updated(): void
    {
        $this->resetPage();
    }

    public function clearFilters(): void
    {
        $this->inStock = false;
        $this->minPrice = null;
        $this->maxPrice = null;
        $this->types = [];
        $this->vendors = [];
        $this->resetPage();
    }

    public function collection(): Collection
    {
        return Collection::query()
            ->where('handle', $this->handle)
            ->where('status', 'active')
            ->firstOrFail();
    }

    public function products(): LengthAwarePaginator
    {
        $collection = $this->collection();

        return $collection->products()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->when($this->inStock, function (Builder $query): void {
                $query->whereHas('variants.inventoryItem', function (Builder $query): void {
                    $query
                        ->where('policy', 'continue')
                        ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved');
                });
            })
            ->when($this->types !== [], fn (Builder $query) => $query->whereIn('product_type', $this->types))
            ->when($this->vendors !== [], fn (Builder $query) => $query->whereIn('vendor', $this->vendors))
            ->when($this->minPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '>=', $this->minPrice * 100));
            })
            ->when($this->maxPrice !== null, function (Builder $query): void {
                $query->whereHas('variants', fn (Builder $query) => $query->where('price_amount', '<=', $this->maxPrice * 100));
            })
            ->when($this->sort === 'price_asc', fn (Builder $query) => $query->orderBy(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'price_desc', fn (Builder $query) => $query->orderByDesc(ProductVariant::select('price_amount')->whereColumn('product_id', 'products.id')->orderBy('price_amount')->limit(1)))
            ->when($this->sort === 'newest', fn (Builder $query) => $query->latest('products.created_at'))
            ->paginate(12);
    }

    public function productTypes(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function productVendors(): SupportCollection
    {
        return $this->collection()->products()
            ->where('products.status', 'active')
            ->whereNotNull('products.published_at')
            ->whereNotNull('vendor')
            ->distinct()
            ->orderBy('vendor')
            ->pluck('vendor');
    }

    public function render(): mixed
    {
        $collection = $this->collection();

        return view('livewire.storefront.collections.show', [
            'collection' => $collection,
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
            'productVendors' => $this->productVendors(),
        ])->layout('layouts.storefront', [
            'title' => $collection->title,
        ]);
    }
}

    <x-storefront.breadcrumbs :items="[
        ['label' => 'Collections', 'url' => route('collections.index')],
        ['label' => $collection->title],
    ]" />

{{ $collection->title }}

        @if ($collection->description_html)

{!! $collection->description_html !!}

        @endif

Filters

                <button wire:click="clearFilters" class="text-sm font-medium text-blue-700 hover:underline dark:text-blue-300">Clear all</button>

                <flux:checkbox wire:model.live="inStock" label="In stock" />

Price

                        <flux:input wire:model.live.debounce.500ms="minPrice" type="number" min="0" placeholder="Min" aria-label="Minimum price" />
                        <flux:input wire:model.live.debounce.500ms="maxPrice" type="number" min="0" placeholder="Max" aria-label="Maximum price" />

Product type

                        @foreach ($productTypes as $type)
                            <flux:checkbox wire:model.live="types" value="{{ $type }}" :label="$type" wire:key="type-filter-{{ $type }}" />
                        @endforeach

Vendor

                        @foreach ($productVendors as $vendor)
                            <flux:checkbox wire:model.live="vendors" value="{{ $vendor }}" :label="$vendor" wire:key="vendor-filter-{{ $vendor }}" />
                        @endforeach

{{ $products->total() }} products

                <flux:select wire:model.live="sort" aria-label="Sort products" class="sm:max-w-56">
                    <flux:select.option value="featured">Featured</flux:select.option>
                    <flux:select.option value="price_asc">Price: Low to High</flux:select.option>
                    <flux:select.option value="price_desc">Price: High to Low</flux:select.option>
                    <flux:select.option value="newest">Newest</flux:select.option>
                </flux:select>

            @if ($products->isEmpty())

                        <flux:icon name="magnifying-glass" class="size-10 text-zinc-400" />

No products found

Try adjusting your filters or browse another collection.

                        <flux:button wire:click="clearFilters" variant="ghost">Clear filters</flux:button>

            @else

                    @foreach ($products as $product)
                        <x-storefront.product-card :product="$product" wire:key="collection-product-{{ $product->getKey() }}" />
                    @endforeach

                {{ $products->links() }}
            @endif