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

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('search_settings', function (Blueprint $table) {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->text('synonyms_json')->default('[]');
            $table->text('stop_words_json')->default('[]');
            $table->timestamp('updated_at')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('search_settings');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('search_queries', function (Blueprint $table) {
            $table->id();
            $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');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('search_queries');
    }
};


Question: text


sed -n '1,220p' app/Models/SearchSettings.php


rg -n "products_fts|reindex|SearchSettings|search status|search/reindex" app database tests routes specs


sed -n '1,220p' database/migrations/*products_fts*


sed -n '1,240p' tests/Feature/Storefront/SearchTest.php


sed -n '1,220p' app/Models/SearchSettings.php

<?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 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',
        ];
    }
}


sed -n '1,220p' database/migrations/*products_fts*

sed -n '1,220p' 'database/migrations/*products_fts*'

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        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);
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('products_fts');
    }
};


sed -n '1,240p' tests/Feature/Storefront/SearchTest.php

sed: tests/Feature/Storefront/SearchTest.php: No such file or directory


rg -n "products_fts|reindex|SearchSettings|search status|search/reindex" app database tests routes specs

rg -n 'products_fts|reindex|SearchSettings|search status|search/reindex' app database tests routes specs

Question: products_fts|reindex|SearchSettings|search status|search/reindex

routes/web.php:25:use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
routes/web.php:111:    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
database/factories/SearchSettingsFactory.php:9: * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchSettings>
database/factories/SearchSettingsFactory.php:11:class SearchSettingsFactory extends Factory
specs/03-ADMIN-UI.md:2223:| `lastIndexedAt` | `?string` | Last reindex timestamp |
specs/03-ADMIN-UI.md:2224:| `isReindexing` | `bool` | Whether a reindex is in progress |
specs/03-ADMIN-UI.md:2225:| `reindexProgress` | `?int` | Progress percentage (0-100) |
specs/03-ADMIN-UI.md:2232:| `triggerReindex()` | Dispatches a reindex job |
specs/03-ADMIN-UI.md:2233:| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |
specs/03-ADMIN-UI.md:2261:| (when reindexing:)                                                |
specs/03-ADMIN-UI.md:2284:- "Reindex now" button (disabled while reindexing, text changes to "Reindexing...")
specs/03-ADMIN-UI.md:2286:- When reindexing: a progress bar (polled every 2 seconds) with percentage text
specs/02-API-ROUTES.md:142:| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |
specs/02-API-ROUTES.md:2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
specs/02-API-ROUTES.md:2724:Trigger a full search reindex for the store. Runs asynchronously via a queued job.
specs/02-API-ROUTES.md:2731:  "job_id": "job_reindex_abc123",
specs/02-API-ROUTES.md:2754:    "last_reindex_at": "2026-02-11T08:00:00Z",
specs/02-API-ROUTES.md:2755:    "last_reindex_duration_seconds": 45,
specs/09-IMPLEMENTATION-ROADMAP.md:849:| `Admin\Search\Settings` | Synonyms, stop words, reindex button |
specs/09-IMPLEMENTATION-ROADMAP.md:864:3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table
specs/09-IMPLEMENTATION-ROADMAP.md:1738:| `SearchSettings` | Per-store search configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:2063:18. `SearchSettingsSeeder`
specs/progress.md:29:| Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
specs/progress.md:31:| Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
specs/progress.md:36:| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser smoke coverage now checks storefront core pages, authenticated admin core pages, and mobile sto…2041 chars truncated…eature/Search/SearchServiceTest.php:5:use App\Models\SearchSettings;
tests/Feature/Search/SearchServiceTest.php:79:    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
tests/Feature/Search/SearchServiceTest.php:107:    SearchSettings::withoutGlobalScopes()->updateOrCreate(
tests/Feature/Search/SearchServiceTest.php:132:    SearchSettings::withoutGlobalScopes()->updateOrCreate(
tests/Feature/Admin/SearchSettingsTest.php:3:use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
tests/Feature/Admin/SearchSettingsTest.php:5:use App\Models\SearchSettings;
tests/Feature/Admin/SearchSettingsTest.php:21:function adminSearchSettingsStore(): Store
tests/Feature/Admin/SearchSettingsTest.php:26:function adminSearchSettingsUser(): User
tests/Feature/Admin/SearchSettingsTest.php:32:    $this->actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:40:    $store = adminSearchSettingsStore();
tests/Feature/Admin/SearchSettingsTest.php:43:    Livewire::actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:44:        ->test(SearchSettingsComponent::class)
tests/Feature/Admin/SearchSettingsTest.php:50:    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
tests/Feature/Admin/SearchSettingsTest.php:59:    $store = adminSearchSettingsStore();
tests/Feature/Admin/SearchSettingsTest.php:67:            'handle' => 'reindex-search-jacket',
tests/Feature/Admin/SearchSettingsTest.php:70:    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
tests/Feature/Admin/SearchSettingsTest.php:72:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);
tests/Feature/Admin/SearchSettingsTest.php:74:    Livewire::actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:75:        ->test(SearchSettingsComponent::class)
tests/Feature/Admin/SearchSettingsTest.php:79:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
app/Services/SearchService.php:11:use App\Models\SearchSettings;
app/Services/SearchService.php:57:            ->orderByRaw('products_fts.rank')
app/Services/SearchService.php:169:        DB::table('products_fts')
app/Services/SearchService.php:173:        DB::table('products_fts')->insert([
app/Services/SearchService.php:186:        DB::table('products_fts')
app/Services/SearchService.php:191:    public function reindex(Store $store): int
app/Services/SearchService.php:193:        DB::table('products_fts')
app/Services/SearchService.php:240:            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
app/Services/SearchService.php:241:            ->where('products_fts.store_id', $store->getKey())
app/Services/SearchService.php:242:            ->whereRaw('products_fts MATCH ?', [$match]);
app/Services/SearchService.php:352:                ? $builder->orderByRaw('products_fts.rank')
app/Services/SearchService.php:421:    private function synonymExpressions(?SearchSettings $settings): array
app/Services/SearchService.php:466:    private function settings(Store $store): ?SearchSettings
app/Services/SearchService.php:468:        return SearchSettings::withoutGlobalScopes()
database/seeders/DatabaseSeeder.php:22:            SearchSettingsSeeder::class,
database/migrations/2026_05_04_030848_create_products_fts_table.php:15:            CREATE VIRTUAL TABLE products_fts USING fts5(
database/migrations/2026_05_04_030848_create_products_fts_table.php:28:            INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags)
database/migrations/2026_05_04_030848_create_products_fts_table.php:39:        Schema::dropIfExists('products_fts');
database/seeders/SearchSettingsSeeder.php:5:use App\Models\SearchSettings;
database/seeders/SearchSettingsSeeder.php:9:class SearchSettingsSeeder extends Seeder
database/seeders/SearchSettingsSeeder.php:19:                SearchSettings::withoutGlobalScopes()->updateOrCreate(
app/Livewire/Admin/Search/Settings.php:5:use App\Models\SearchSettings;
app/Livewire/Admin/Search/Settings.php:30:    public ?int $reindexProgress = null;
app/Livewire/Admin/Search/Settings.php:92:        $this->reindexProgress = 10;
app/Livewire/Admin/Search/Settings.php:94:        $count = $search->reindex($store);
app/Livewire/Admin/Search/Settings.php:100:        $this->reindexProgress = 100;
app/Livewire/Admin/Search/Settings.php:109:        $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null;
app/Livewire/Admin/Search/Settings.php:144:    private function settings(): SearchSettings
app/Livewire/Admin/Search/Settings.php:146:        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
app/Models/SearchSettings.php:10:class SearchSettings extends Model
app/Models/SearchSettings.php:12:    /** @use HasFactory<\Database\Factories\SearchSettingsFactory> */


Output:
Chunk ID: 5ea434
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 298
Output:
<?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 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',
        ];
    }
}


Output:
Chunk ID: 8e9625
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3011
Output:
routes/web.php:25:use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
routes/web.php:111:    Route::livewire('search/settings', AdminSearchSettings::class)->name('search.settings');
database/factories/SearchSettingsFactory.php:9: * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SearchSettings>
database/factories/SearchSettingsFactory.php:11:class SearchSettingsFactory extends Factory
specs/03-ADMIN-UI.md:2223:| `lastIndexedAt` | `?string` | Last reindex timestamp |
specs/03-ADMIN-UI.md:2224:| `isReindexing` | `bool` | Whether a reindex is in progress |
specs/03-ADMIN-UI.md:2225:| `reindexProgress` | `?int` | Progress percentage (0-100) |
specs/03-ADMIN-UI.md:2232:| `triggerReindex()` | Dispatches a reindex job |
specs/03-ADMIN-UI.md:2233:| `pollReindexStatus()` | Checks reindex progress (polled every 2 seconds) |
specs/03-ADMIN-UI.md:2261:| (when reindexing:)                                                |
specs/03-ADMIN-UI.md:2284:- "Reindex now" button (disabled while reindexing, text changes to "Reindexing...")
specs/03-ADMIN-UI.md:2286:- When reindexing: a progress bar (polled every 2 seconds) with percentage text
specs/02-API-ROUTES.md:142:| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |
specs/02-API-ROUTES.md:2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
specs/02-API-ROUTES.md:2724:Trigger a full search reindex for the store. Runs asynchronously via a queued job.
specs/02-API-ROUTES.md:2731:  "job_id": "job_reindex_abc123",
specs/02-API-ROUTES.md:2754:    "last_reindex_at": "2026-02-11T08:00:00Z",
specs/02-API-ROUTES.md:2755:    "last_reindex_duration_seconds": 45,
specs/09-IMPLEMENTATION-ROADMAP.md:849:| `Admin\Search\Settings` | Synonyms, stop words, reindex button |
specs/09-IMPLEMENTATION-ROADMAP.md:864:3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table
specs/09-IMPLEMENTATION-ROADMAP.md:1738:| `SearchSettings` | Per-store search configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:2063:18. `SearchSettingsSeeder`
specs/progress.md:29:| Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
specs/progress.md:31:| Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
specs/progress.md:36:| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser smoke coverage now checks storefront core pages, authenticated admin core pages, and mobile storefront rendering for JavaScript errors. Full Spec 08 browser suites are still incomplete. |
specs/progress.md:199:- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed: 10 tests, 46 assertions.
specs/progress.md:200:- 2026-05-04: `php artisan test --compact tests/Feature/Api tests/Feature/Search tests/Feature/Admin/SearchSettingsTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 25 tests, 201 assertions.
specs/progress.md:204:- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after the search changes; Boost query counts after browser smoke: search_settings 2, search_queries 2, products_fts 25.
specs/progress.md:219:- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after browser verification to restore seeded fixtures; Boost query counts after reset: analytics_daily 60, analytics_events 210, search_settings 2, products_fts 25.
specs/progress.md:258:- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions.
specs/progress.md:399:- Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete.
specs/07-SEEDERS-AND-TEST-DATA.md:32:| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |
specs/07-SEEDERS-AND-TEST-DATA.md:59:    B --> R[SearchSettingsSeeder]
specs/07-SEEDERS-AND-TEST-DATA.md:2075:### 3.18 SearchSettingsSeeder
specs/05-BUSINESS-LOGIC.md:1535:Create an FTS5 virtual table named `products_fts` with these columns:
specs/05-BUSINESS-LOGIC.md:1553:| `created` | Insert row into products_fts |
specs/05-BUSINESS-LOGIC.md:1555:| `deleted` | Delete row from products_fts |
tests/Feature/Search/SearchServiceTest.php:5:use App\Models\SearchSettings;
tests/Feature/Search/SearchServiceTest.php:79:    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
tests/Feature/Search/SearchServiceTest.php:107:    SearchSettings::withoutGlobalScopes()->updateOrCreate(
tests/Feature/Search/SearchServiceTest.php:132:    SearchSettings::withoutGlobalScopes()->updateOrCreate(
tests/Feature/Admin/SearchSettingsTest.php:3:use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
tests/Feature/Admin/SearchSettingsTest.php:5:use App\Models\SearchSettings;
tests/Feature/Admin/SearchSettingsTest.php:21:function adminSearchSettingsStore(): Store
tests/Feature/Admin/SearchSettingsTest.php:26:function adminSearchSettingsUser(): User
tests/Feature/Admin/SearchSettingsTest.php:32:    $this->actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:40:    $store = adminSearchSettingsStore();
tests/Feature/Admin/SearchSettingsTest.php:43:    Livewire::actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:44:        ->test(SearchSettingsComponent::class)
tests/Feature/Admin/SearchSettingsTest.php:50:    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
tests/Feature/Admin/SearchSettingsTest.php:59:    $store = adminSearchSettingsStore();
tests/Feature/Admin/SearchSettingsTest.php:67:            'handle' => 'reindex-search-jacket',
tests/Feature/Admin/SearchSettingsTest.php:70:    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
tests/Feature/Admin/SearchSettingsTest.php:72:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);
tests/Feature/Admin/SearchSettingsTest.php:74:    Livewire::actingAs(adminSearchSettingsUser())
tests/Feature/Admin/SearchSettingsTest.php:75:        ->test(SearchSettingsComponent::class)
tests/Feature/Admin/SearchSettingsTest.php:79:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
app/Services/SearchService.php:11:use App\Models\SearchSettings;
app/Services/SearchService.php:57:            ->orderByRaw('products_fts.rank')
app/Services/SearchService.php:169:        DB::table('products_fts')
app/Services/SearchService.php:173:        DB::table('products_fts')->insert([
app/Services/SearchService.php:186:        DB::table('products_fts')
app/Services/SearchService.php:191:    public function reindex(Store $store): int
app/Services/SearchService.php:193:        DB::table('products_fts')
app/Services/SearchService.php:240:            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
app/Services/SearchService.php:241:            ->where('products_fts.store_id', $store->getKey())
app/Services/SearchService.php:242:            ->whereRaw('products_fts MATCH ?', [$match]);
app/Services/SearchService.php:352:                ? $builder->orderByRaw('products_fts.rank')
app/Services/SearchService.php:421:    private function synonymExpressions(?SearchSettings $settings): array
app/Services/SearchService.php:466:    private function settings(Store $store): ?SearchSettings
app/Services/SearchService.php:468:        return SearchSettings::withoutGlobalScopes()
database/seeders/DatabaseSeeder.php:22:            SearchSettingsSeeder::class,
database/migrations/2026_05_04_030848_create_products_fts_table.php:15:            CREATE VIRTUAL TABLE products_fts USING fts5(
database/migrations/2026_05_04_030848_create_products_fts_table.php:28:            INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags)
database/migrations/2026_05_04_030848_create_products_fts_table.php:39:        Schema::dropIfExists('products_fts');
database/seeders/SearchSettingsSeeder.php:5:use App\Models\SearchSettings;
database/seeders/SearchSettingsSeeder.php:9:class SearchSettingsSeeder extends Seeder
database/seeders/SearchSettingsSeeder.php:19:                SearchSettings::withoutGlobalScopes()->updateOrCreate(
app/Livewire/Admin/Search/Settings.php:5:use App\Models\SearchSettings;
app/Livewire/Admin/Search/Settings.php:30:    public ?int $reindexProgress = null;
app/Livewire/Admin/Search/Settings.php:92:        $this->reindexProgress = 10;
app/Livewire/Admin/Search/Settings.php:94:        $count = $search->reindex($store);
app/Livewire/Admin/Search/Settings.php:100:        $this->reindexProgress = 100;
app/Livewire/Admin/Search/Settings.php:109:        $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null;
app/Livewire/Admin/Search/Settings.php:144:    private function settings(): SearchSettings
app/Livewire/Admin/Search/Settings.php:146:        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
app/Models/SearchSettings.php:10:class SearchSettings extends Model
app/Models/SearchSettings.php:12:    /** @use HasFactory<\Database\Factories\SearchSettingsFactory> */


Output:
Chunk ID: 10d89a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 268
Output:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        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);
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('products_fts');
    }
};


Output:
Chunk ID: 3efb03
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 18
Output:
sed: tests/Feature/Storefront/SearchTest.php: No such file or directory


sed -n '1,160p' tests/Feature/Admin/SearchSettingsTest.php


sed -n '1,180p' tests/Feature/Search/SearchServiceTest.php


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


sed -n '1,160p' tests/Feature/Admin/SearchSettingsTest.php

<?php

use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Models\User;
use App\Services\SearchService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

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

function adminSearchSettingsStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

test('admin search settings route renders for store admins', function (): void {
    $this->actingAs(adminSearchSettingsUser())
        ->get('/admin/search/settings')
        ->assertSuccessful()
        ->assertSee('Search settings')
        ->assertSee('Synonyms');
});

test('admin search settings saves synonyms and stop words', function (): void {
    $store = adminSearchSettingsStore();
    app()->instance('current_store', $store);

    Livewire::actingAs(adminSearchSettingsUser())
        ->test(SearchSettingsComponent::class)
        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
        ->set('stopWords', 'the, and, for')
        ->call('save')
        ->assertSee('Search settings saved');

    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    expect($settings->synonyms_json)->toBe([
        ['hoodie', 'sweatshirt', 'pullover'],
        ['tee', 't-shirt'],
    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
});

test('admin search settings can rebuild the search index', function (): void {
    $store = adminSearchSettingsStore();
    app()->instance('current_store', $store);

    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(2999)
        ->create([
            'title' => 'Reindex Search Jacket',
            'handle' => 'reindex-search-jacket',
        ]);

    DB::table('products_fts')->where('product_id', $product->getKey())->delete();

    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);

    Livewire::actingAs(adminSearchSettingsUser())
        ->test(SearchSettingsComponent::class)
        ->call('triggerReindex')
        ->assertSee('Search index rebuilt');

    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
});


sed -n '1,180p' tests/Feature/Search/SearchServiceTest.php

<?php

use App\Models\Product;
use App\Models\SearchQuery;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Services\SearchService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

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

function searchServiceStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

test('search service returns matching products scoped to the store and logs the query', function (): void {
    $store = searchServiceStore();
    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Blue Viscose Search Shirt',
            'handle' => 'blue-viscose-search-shirt',
            'vendor' => 'Search Vendor',
            'tags' => ['viscose', 'blue'],
        ]);

    Product::factory()
        ->for($otherStore)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Blue Viscose Search Shirt Deluxe',
            'handle' => 'blue-viscose-search-shirt-deluxe',
            'vendor' => 'Other Vendor',
            'tags' => ['viscose'],
        ]);

    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Blue Viscose Search Shirt');

    $queryLog = SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->latest('id')->firstOrFail();

    expect($queryLog->query)->toBe('viscose search')
        ->and($queryLog->results_count)->toBe(1);
});

test('search service keeps the FTS index synchronized through product observer events', function (): void {
    $store = searchServiceStore();

    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(1599)
        ->create([
            'title' => 'Copper Index Sync Jacket',
            'handle' => 'copper-index-sync-jacket',
        ]);

    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);

    $product->update(['title' => 'Graphite Index Sync Jacket']);

    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);

    $product->delete();

    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
});

test('search service paginates unique matches', function (): void {
    $store = searchServiceStore();

    foreach (range(1, 25) as $index) {
        Product::factory()
            ->for($store)
            ->withDefaultVariant(1000 + $index)
            ->create([
                'title' => "Pagination Merino Search {$index}",
                'handle' => "pagination-merino-search-{$index}",
            ]);
    }

    request()->query->set('page', 3);

    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);

    expect($results->total())->toBe(25)
        ->and($results->currentPage())->toBe(3)
        ->and($results->getCollection())->toHaveCount(1);
});

test('search service expands configured synonyms', function (): void {
    $store = searchServiceStore();

    SearchSettings::withoutGlobalScopes()->updateOrCreate(
        ['store_id' => $store->getKey()],
        [
            'synonyms_json' => [['tee', 'tshirt']],
            'stop_words_json' => [],
        ],
    );

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Emerald Tee Synonym Match',
            'handle' => 'emerald-tee-synonym-match',
        ]);

    $results = app(SearchService::class)->search($store, 'tshirt synonym', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Emerald Tee Synonym Match');
});

test('search service removes configured stop words before matching', function (): void {
    $store = searchServiceStore();

    SearchSettings::withoutGlobalScopes()->updateOrCreate(
        ['store_id' => $store->getKey()],
        [
            'synonyms_json' => [],
            'stop_words_json' => ['the', 'for'],
        ],
    );

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Stopword Linen Token',
            'handle' => 'stopword-linen-token',
        ]);

    $results = app(SearchService::class)->search($store, 'the stopword', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Stopword Linen Token');
});


Output:
Chunk ID: 872c7f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 654
Output:
<?php

use App\Livewire\Admin\Search\Settings as SearchSettingsComponent;
use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Models\User;
use App\Services\SearchService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

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

function adminSearchSettingsStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

test('admin search settings route renders for store admins', function (): void {
    $this->actingAs(adminSearchSettingsUser())
        ->get('/admin/search/settings')
        ->assertSuccessful()
        ->assertSee('Search settings')
        ->assertSee('Synonyms');
});

test('admin search settings saves synonyms and stop words', function (): void {
    $store = adminSearchSettingsStore();
    app()->instance('current_store', $store);

    Livewire::actingAs(adminSearchSettingsUser())
        ->test(SearchSettingsComponent::class)
        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
        ->set('stopWords', 'the, and, for')
        ->call('save')
        ->assertSee('Search settings saved');

    $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();

    expect($settings->synonyms_json)->toBe([
        ['hoodie', 'sweatshirt', 'pullover'],
        ['tee', 't-shirt'],
    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
});

test('admin search settings can rebuild the search index', function (): void {
    $store = adminSearchSettingsStore();
    app()->instance('current_store', $store);

    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(2999)
        ->create([
            'title' => 'Reindex Search Jacket',
            'handle' => 'reindex-search-jacket',
        ]);

    DB::table('products_fts')->where('product_id', $product->getKey())->delete();

    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(0);

    Livewire::actingAs(adminSearchSettingsUser())
        ->test(SearchSettingsComponent::class)
        ->call('triggerReindex')
        ->assertSee('Search index rebuilt');

    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
});


Output:
Chunk ID: 5eb702
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1221
Output:
<?php

use App\Models\Product;
use App\Models\SearchQuery;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Services\SearchService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

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

function searchServiceStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

test('search service returns matching products scoped to the store and logs the query', function (): void {
    $store = searchServiceStore();
    $otherStore = Store::query()->whereKeyNot($store->getKey())->firstOrFail();

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Blue Viscose Search Shirt',
            'handle' => 'blue-viscose-search-shirt',
            'vendor' => 'Search Vendor',
            'tags' => ['viscose', 'blue'],
        ]);

    Product::factory()
        ->for($otherStore)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Blue Viscose Search Shirt Deluxe',
            'handle' => 'blue-viscose-search-shirt-deluxe',
            'vendor' => 'Other Vendor',
            'tags' => ['viscose'],
        ]);

    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Blue Viscose Search Shirt');

    $queryLog = SearchQuery::withoutGlobalScopes()->where('store_id', $store->getKey())->latest('id')->firstOrFail();

    expect($queryLog->query)->toBe('viscose search')
        ->and($queryLog->results_count)->toBe(1);
});

test('search service keeps the FTS index synchronized through product observer events', function (): void {
    $store = searchServiceStore();

    $product = Product::factory()
        ->for($store)
        ->withDefaultVariant(1599)
        ->create([
            'title' => 'Copper Index Sync Jacket',
            'handle' => 'copper-index-sync-jacket',
        ]);

    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);

    $product->update(['title' => 'Graphite Index Sync Jacket']);

    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);

    $product->delete();

    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
});

test('search service paginates unique matches', function (): void {
    $store = searchServiceStore();

    foreach (range(1, 25) as $index) {
        Product::factory()
            ->for($store)
            ->withDefaultVariant(1000 + $index)
            ->create([
                'title' => "Pagination Merino Search {$index}",
                'handle' => "pagination-merino-search-{$index}",
            ]);
    }

    request()->query->set('page', 3);

    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);

    expect($results->total())->toBe(25)
        ->and($results->currentPage())->toBe(3)
        ->and($results->getCollection())->toHaveCount(1);
});

test('search service expands configured synonyms', function (): void {
    $store = searchServiceStore();

    SearchSettings::withoutGlobalScopes()->updateOrCreate(
        ['store_id' => $store->getKey()],
        [
            'synonyms_json' => [['tee', 'tshirt']],
            'stop_words_json' => [],
        ],
    );

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Emerald Tee Synonym Match',
            'handle' => 'emerald-tee-synonym-match',
        ]);

    $results = app(SearchService::class)->search($store, 'tshirt synonym', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Emerald Tee Synonym Match');
});

test('search service removes configured stop words before matching', function (): void {
    $store = searchServiceStore();

    SearchSettings::withoutGlobalScopes()->updateOrCreate(
        ['store_id' => $store->getKey()],
        [
            'synonyms_json' => [],
            'stop_words_json' => ['the', 'for'],
        ],
    );

    Product::factory()
        ->for($store)
        ->withDefaultVariant(1999)
        ->create([
            'title' => 'Stopword Linen Token',
            'handle' => 'stopword-linen-token',
        ]);

    $results = app(SearchService::class)->search($store, 'the stopword', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->getCollection()->first()->title)->toBe('Stopword Linen Token');
});


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

<?php

namespace Database\Factories;

use App\Models\Store;
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 [
            'store_id' => Store::factory(),
            'synonyms_json' => [
                ['t-shirt', 'tee', 'tshirt'],
                ['sneakers', 'trainers'],
            ],
            'stop_words_json' => ['the', 'a', 'an'],
        ];
    }
}


Output:
Chunk ID: 097ee3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 168
Output:
<?php

namespace Database\Factories;

use App\Models\Store;
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 [
            'store_id' => Store::factory(),
            'synonyms_json' => [
                ['t-shirt', 'tee', 'tshirt'],
                ['sneakers', 'trainers'],
            ],
            'stop_words_json' => ['the', 'a', 'an'],
        ];
    }
}


The search status can be computed from existing `products` and `products_fts` rows, and reindexing already exists on `SearchService`. I’m adding a thin API controller plus focused tests for session auth, token abilities, stale status, and reindex repair.


php artisan make:controller Api/Admin/V1/SearchIndexController --no-interaction


php artisan make:test Api/AdminSearchIndexApiTest --pest --no-interaction


Output:
Chunk ID: c04a9d
Wall time: 0.1546 seconds
Process exited with code 0
Original token count: 27
Output:

   INFO  Controller [app/Http/Controllers/Api/Admin/V1/SearchIndexController.php] created successfully.


Output:
Chunk ID: 2bd3d3
Wall time: 0.1606 seconds
Process exited with code 0
Original token count: 22
Output:

   INFO  Test [tests/Feature/Api/AdminSearchIndexApiTest.php] created successfully.


php artisan make:controller Api/Admin/V1/SearchIndexController --no-interaction

INFO  Controller [app/Http/Controllers/Api/Admin/V1/SearchIndexController.php] created successfully.


php artisan make:test Api/AdminSearchIndexApiTest --pest --no-interaction

INFO  Test [tests/Feature/Api/AdminSearchIndexApiTest.php] created successfully.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
@@
 <?php

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

 use App\Http\Controllers\Controller;
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;

 class SearchIndexController extends Controller
 {
-    //
+    public function reindex(Request $request, Store $store, SearchService $search): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $startedAt = microtime(true);
+        $count = $search->reindex($store);
+        $duration = (int) ceil(microtime(true) - $startedAt);
+
+        $settings = $this->settings($store);
+        $settings->forceFill([
+            'updated_at' => now(),
+        ])->save();
+
+        return response()->json([
+            'message' => __('Reindex completed.'),
+            'job_id' => null,
+            'status' => 'completed',
+            'documents_count' => $count,
+            'last_reindex_duration_seconds' => $duration,
+        ], 202);
+    }
+
+    public function status(Request $request, Store $store): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $productCount = Product::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->count();
+
+        $documentsCount = DB::table('products_fts')
+            ->where('store_id', $store->getKey())
+            ->distinct()
+            ->count('product_id');
+
+        $pendingUpdates = abs($productCount - $documentsCount);
+        $settings = $this->settings($store);
+
+        return response()->json([
+            'data' => [
+                'store_id' => $store->getKey(),
+                'index_status' => $pendingUpdates === 0 ? 'ready' : 'stale',
+                'last_reindex_at' => $settings->updated_at?->toIso8601String(),
+                'last_reindex_duration_seconds' => 0,
+                'documents_count' => $documentsCount,
+                'pending_updates' => $pendingUpdates,
+            ],
+        ]);
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function settings(Store $store): SearchSettings
+    {
+        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
+            'store_id' => $store->getKey(),
+        ]);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/routes/api.php
@@
 use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
 use App\Http\Controllers\Api\Admin\V1\PageController as AdminPageController;
 use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
+use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
 use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
@@
         Route::middleware('admin.api:write-content')->group(function (): void {
             Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store');
             Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update');
             Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy');
         });

+        Route::middleware('admin.api:read-settings')->group(function (): void {
+            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+        });
+
+        Route::middleware('admin.api:write-settings')->group(function (): void {
+            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
         });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminSearchIndexApiTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\SearchService;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminSearchIndexApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminSearchIndexApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminSearchIndexApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Search integration', $abilities);
+}
+
+test('admin search index api reports status and rebuilds stale documents', function (): void {
+    $store = adminSearchIndexApiStore();
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2999)
+        ->create([
+            'title' => 'API Reindex Linen Jacket',
+            'handle' => 'api-reindex-linen-jacket',
+        ]);
+
+    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
+
+    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(0);
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk()
+        ->assertJsonPath('data.index_status', 'stale')
+        ->assertJsonPath('data.pending_updates', 1);
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertAccepted()
+        ->assertJsonPath('status', 'completed')
+        ->assertJsonPath('documents_count', Product::withoutGlobalScopes()->where('store_id', $store->getKey())->count());
+
+    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(1)
+        ->and(SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail()->updated_at)->not->toBeNull();
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk()
+        ->assertJsonPath('data.index_status', 'ready')
+        ->assertJsonPath('data.pending_updates', 0);
+});
+
+test('admin search index api enforces token abilities and store scope', function (): void {
+    $store = adminSearchIndexApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminSearchIndexApiToken($store, ['read-settings']);
+    $writeToken = adminSearchIndexApiToken($store, ['write-settings']);
+    $otherStoreToken = adminSearchIndexApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertAccepted();
 });
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
M /Users/fabianwesner/Herd/shop/routes/api.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminSearchIndexApiTest.php

@@ -10,2 +10,3 @@
 use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
+use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
 use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
@@ -97,2 +98,10 @@

+        Route::middleware('admin.api:read-settings')->group(function (): void {
+            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+        });
+
+        Route::middleware('admin.api:write-settings')->group(function (): void {
+            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {

@@ -2,6 +2,96 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\SearchService;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminSearchIndexApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminSearchIndexApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminSearchIndexApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Search integration', $abilities);
+}
+
+test('admin search index api reports status and rebuilds stale documents', function (): void {
+    $store = adminSearchIndexApiStore();
+    $product = Product::factory()
+        ->for($store)
+        ->withDefaultVariant(2999)
+        ->create([
+            'title' => 'API Reindex Linen Jacket',
+            'handle' => 'api-reindex-linen-jacket',
+        ]);
+
+    DB::table('products_fts')->where('product_id', $product->getKey())->delete();
+
+    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(0);
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk()
+        ->assertJsonPath('data.index_status', 'stale')
+        ->assertJsonPath('data.pending_updates', 1);
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertAccepted()
+        ->assertJsonPath('status', 'completed')
+        ->assertJsonPath('documents_count', Product::withoutGlobalScopes()->where('store_id', $store->getKey())->count());
+
+    expect(app(SearchService::class)->search($store, 'api reindex linen', [], 12)->total())->toBe(1)
+        ->and(SearchSettings::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail()->updated_at)->not->toBeNull();
+
+    $this->actingAs(adminSearchIndexApiUser())
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk()
+        ->assertJsonPath('data.index_status', 'ready')
+        ->assertJsonPath('data.pending_updates', 0);
+});
+
+test('admin search index api enforces token abilities and store scope', function (): void {
+    $store = adminSearchIndexApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminSearchIndexApiToken($store, ['read-settings']);
+    $writeToken = adminSearchIndexApiToken($store, ['write-settings']);
+    $otherStoreToken = adminSearchIndexApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/search/status")
+        ->assertForbidden();

-    $response->assertStatus(200);
+    $this->withToken($writeToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/search/reindex")
+        ->assertAccepted();
 });

@@ -5,3 +5,9 @@
 use App\Http\Controllers\Controller;
+use App\Models\Product;
+use App\Models\SearchSettings;
+use App\Models\Store;
+use App\Services\SearchService;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;

@@ -9,3 +15,67 @@
 {
-    //
+    public function reindex(Request $request, Store $store, SearchService $search): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $startedAt = microtime(true);
+        $count = $search->reindex($store);
+        $duration = (int) ceil(microtime(true) - $startedAt);
+
+        $settings = $this->settings($store);
+        $settings->forceFill([
+            'updated_at' => now(),
+        ])->save();
+
+        return response()->json([
+            'message' => __('Reindex completed.'),
+            'job_id' => null,
+            'status' => 'completed',
+            'documents_count' => $count,
+            'last_reindex_duration_seconds' => $duration,
+        ], 202);
+    }
+
+    public function status(Request $request, Store $store): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $productCount = Product::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->count();
+
+        $documentsCount = DB::table('products_fts')
+            ->where('store_id', $store->getKey())
+            ->distinct()
+            ->count('product_id');
+
+        $pendingUpdates = abs($productCount - $documentsCount);
+        $settings = $this->settings($store);
+
+        return response()->json([
+            'data' => [
+                'store_id' => $store->getKey(),
+                'index_status' => $pendingUpdates === 0 ? 'ready' : 'stale',
+                'last_reindex_at' => $settings->updated_at?->toIso8601String(),
+                'last_reindex_duration_seconds' => 0,
+                'documents_count' => $documentsCount,
+                'pending_updates' => $pendingUpdates,
+            ],
+        ]);
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function settings(Store $store): SearchSettings
+    {
+        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
+            'store_id' => $store->getKey(),
+        ]);
+    }
 }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/SearchIndexController.php\nM /Users/fabianwesner/Herd/shop/routes/api.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminSearchIndexApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The search API implementation is now patched in. I’m formatting and running the focused API test to catch any FTS/counting edge cases before widening the run.


Output:
Chunk ID: a736a5
Wall time: 0.3685 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


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


php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php


Output:
Chunk ID: 4cae7f
Wall time: 1.0012 seconds
Process running with session ID 49020
Original token count: 0
Output:


php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php

  ..

  Tests:    2 passed (16 assertions)
  Duration: 1.23s


Output:
Chunk ID: 8fda79
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 16
Output:

  ..

  Tests:    2 passed (16 assertions)
  Duration: 1.23s


The focused search API test is green: 2 tests, 16 assertions. I’m running the full API feature group and checking the admin API route surface now.


php artisan test --compact tests/Feature/Api


php artisan route:list --path=api/admin/v1/stores --except-vendor


php artisan route:list --path=api/admin/v1/stores --except-vendor

  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…

                                                           Showing [22] routes


Output:
Chunk ID: 7effad
Wall time: 1.0023 seconds
Process running with session ID 43750
Original token count: 0
Output:


Output:
Chunk ID: 089d68
Wall time: 0.0691 seconds
Process exited with code 0
Original token count: 468
Output:

  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…

                                                           Showing [22] routes


Output:
Chunk ID: 309e43
Wall time: 5.0017 seconds
Process running with session ID 43750
Original token count: 9
Output:

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


Output:
Chunk ID: 5f7322
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 17
Output:
....

  Tests:    36 passed (285 assertions)
  Duration: 12.31s


The API group is green with the new search endpoints: 36 tests, 285 assertions, and route-list shows 22 admin API routes. I’m recording the search API evidence and narrowing the remaining REST gap again.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminPageApiTest.php` passed after the admin page API changes: 3 tests, 26 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin page API changes: 34 tests, 269 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 20 admin API routes, including page list/create/update/delete.
 - 2026-05-04: `php artisan test --compact` passed after the admin page API changes: 221 tests, 1226 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 JSON responses, 202 Accepted response testing, controller DI, database query counting, and route middleware docs before the admin search index API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/SearchIndexController --no-interaction` and `php artisan make:test Api/AdminSearchIndexApiTest --pest --no-interaction` created the search index API controller and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin search index API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php` passed after the admin search index API changes: 2 tests, 16 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin search index API changes: 36 tests, 285 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 22 admin API routes, including search status and reindex.
@@
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, and order fulfillment/refund mutations.
@@
 - Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete.
+- Admin search reindex API runs the existing local `SearchService::reindex()` synchronously and returns `202 Accepted` with `completed` status because this self-contained app has no separate durable search job state table.
 - Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app.
@@
-- Admin REST surfaces for settings, themes, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/search/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

@@ -29,3 +29,3 @@
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
@@ -354,2 +354,8 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin page API changes: 221 tests, 1226 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 JSON responses, 202 Accepted response testing, controller DI, database query counting, and route middleware docs before the admin search index API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/SearchIndexController --no-interaction` and `php artisan make:test Api/AdminSearchIndexApiTest --pest --no-interaction` created the search index API controller and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin search index API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php` passed after the admin search index API changes: 2 tests, 16 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin search index API changes: 36 tests, 285 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 22 admin API routes, including search status and reindex.

@@ -387,3 +393,3 @@
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
@@ -399,2 +405,3 @@
 - Search uses a local SQLite FTS5 virtual table (`products_fts`) with `SearchService` as the only query/sync boundary; product observer events keep the index synchronized for create/update/delete.
+- Admin search reindex API runs the existing local `SearchService::reindex()` synchronously and returns `202 Accepted` with `completed` status because this self-contained app has no separate durable search job state table.
 - Search settings apply at query time: per-store stop words are removed, synonym groups expand into sanitized SQLite FTS5 `OR` terms, and terms are joined with explicit `AND`; the admin page can rebuild the per-store index synchronously for this self-contained app.
@@ -418,3 +425,3 @@
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- Admin REST surfaces for settings, themes, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
@@ -424,2 +431,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/search/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Progress now reflects the search status/reindex API and its synchronous local behavior. I’m running the full suite before committing this slice.


Output:
Chunk ID: a7b094
Wall time: 1.0007 seconds
Process running with session ID 76196
Original token count: 1
Output:

  .


Output:
Chunk ID: ab0522
Wall time: 5.0004 seconds
Process running with session ID 76196
Original token count: 7
Output:
..........................


Output:
Chunk ID: b472c3
Wall time: 5.0015 seconds
Process running with session ID 76196
Original token count: 8
Output:
...............................


Output:
Chunk ID: 1f8806
Wall time: 5.0018 seconds
Process running with session ID 76196
Original token count: 15
Output:
..................
  .......................................


Output:
Chunk ID: 0e6c94
Wall time: 5.0012 seconds
Process running with session ID 76196
Original token count: 23
Output:
.....................................
  ....................................................


Output:
Chunk ID: 98c6ba
Wall time: 2.4391 seconds
Process exited with code 0
Original token count: 21
Output:
...................

  Tests:    223 passed (1242 assertions)
  Duration: 54.09s


Full suite is green after the search API changes: 223 tests, 1242 assertions. I’m adding that final evidence, then I’ll clean generated logs and commit.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminSearchIndexApiTest.php` passed after the admin search index API changes: 2 tests, 16 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin search index API changes: 36 tests, 285 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 22 admin API routes, including search status and reindex.
+- 2026-05-04: `php artisan test --compact` passed after the admin search index API changes: 223 tests, 1242 assertions.

 ## Decisions
*** End Patch


@@ -360,2 +360,3 @@
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 22 admin API routes, including search status and reindex.
+- 2026-05-04: `php artisan test --compact` passed after the admin search index API changes: 223 tests, 1242 assertions.


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


 routes/api.php    |  9 +++++++++
 specs/progress.md | 16 ++++++++++++----
 2 files changed, 21 insertions(+), 4 deletions(-)


 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
?? tests/Feature/Api/AdminSearchIndexApiTest.php


Output:
Chunk ID: ccb27e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 80
Output:
 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
?? tests/Feature/Api/AdminSearchIndexApiTest.php


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


Output:
Chunk ID: ca2c5e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 32
Output:
 routes/api.php    |  9 +++++++++
 specs/progress.md | 16 ++++++++++++----
 2 files changed, 21 insertions(+), 4 deletions(-)


/bin/zsh
-lc
git clean -f .playwright-mcp

Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


Output:
Chunk ID: bc23b5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


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


sed -n '1,220p' tests/Feature/Api/AdminSearchIndexApiTest.php


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

<?php

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

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Models\SearchSettings;
use App\Models\Store;
use App\Services\SearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class SearchIndexController extends Controller
{
    public function reindex(Request $request, Store $store, SearchService $search): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $startedAt = microtime(true);
        $count = $search->reindex($store);
        $duration = (int) ceil(microtime(true) - $startedAt);

        $settings = $this->settings($store);
        $settings->forceFill([
            'updated_at' => now(),
        ])->save();

        return response()->json([
            'message' => __('Reindex completed.'),
            'job_id' => null,
            'status' => 'completed',
            'documents_count' => $count,
            'last_reindex_duration_seconds' => $duration,
        ], 202);
    }

    public function status(Request $request, Store $store): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $productCount = Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->count();

        $documentsCount = DB::table('products_fts')
            ->where('store_id', $store->getKey())
            ->distinct()
            ->count('product_id');

        $pendingUpdates = abs($productCount - $documentsCount);
        $settings = $this->settings($store);

        return response()->json([
            'data' => [
                'store_id' => $store->getKey(),
                'index_status' => $pendingUpdates === 0 ? 'ready' : 'stale',
                'last_reindex_at' => $settings->updated_at?->toIso8601String(),
                'last_reindex_duration_seconds' => 0,
                'documents_count' => $documentsCount,
                'pending_updates' => $pendingUpdates,
            ],
        ]);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function settings(Store $store): SearchSettings
    {
        return SearchSettings::withoutGlobalScopes()->firstOrCreate([
            'store_id' => $store->getKey(),
        ]);
    }
}


 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
?? tests/Feature/Api/AdminSearchIndexApiTest.php