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

unified_diff:
@@ -32,3 +32,3 @@
 | Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, SQLite-backed search with filters/sort/pagination, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, checkout address/shipping/discount/payment-selection/order-submission UI, order confirmation, customer password reset forms, and customer order list/detail views render seeded and runtime data. |
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService`, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService`, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
 | Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout, customer guard/provider, customer login/register, customer password reset with store-scoped hashed tokens, reset-link throttling, generic reset responses, login rate limiting, session hardening, resource policies, encrypted app client/webhook secrets, and one-time display developer token generation are implemented. Sanctum token auth is still missing. |
@@ -249,2 +249,7 @@
 - 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after product media browser verification to restore seeded fixtures.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/JSON testing docs and Pest 4 dataset/testing docs before the discount redemption-history changes.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the discount service/order allocation changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php` passed: 11 tests, 63 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the discount redemption-history changes: 33 tests, 196 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the discount redemption-history changes: 192 tests, 966 assertions.

@@ -270,3 +275,3 @@
 - Cart page discount codes are validated with `DiscountService`, saved in session, and applied to the checkout when the customer proceeds.
-- Admin discount forms persist minimum purchase, product eligibility, and collection eligibility in the existing `discounts.rules_json` keys consumed by `DiscountService`; `one_per_customer` is captured for future redemption-history enforcement.
+- Admin discount forms persist minimum purchase, product eligibility, collection eligibility, and `one_per_customer` in the existing `discounts.rules_json` keys consumed by `DiscountService`; code discounts now check customer order-line allocation history before allowing another use.
 - `PricingEngine` applies an explicit checkout discount code first, then active automatic discounts returned by `DiscountService`.
@@ -314,4 +319,3 @@
 - OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
-- Discount `one_per_customer` redemption enforcement still needs customer/order usage-history checks; the admin UI captures the rule but `DiscountService` does not enforce it yet.
-- Order line `discount_allocations_json` still attributes explicit checkout codes only; automatic discount attribution needs to be recorded before final discount reporting is complete.
+- Order line `discount_allocations_json` now records explicit checkout discount code and discount id; automatic discount attribution still needs to be recorded before final discount reporting is complete.
 - Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.


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


command:
/bin/zsh
-lc
git diff --check

type: unknown

cmd: git diff --check

status: completed


command:
/bin/zsh
-lc
git diff --stat

cmd: git diff --stat

aggregated_output:
 app/Services/DiscountService.php               | 45 +++++++++++++++++++++++
 app/Services/OrderService.php                  | 11 ++++--
 specs/progress.md                              | 12 ++++---
 tests/Feature/Checkout/PricingServicesTest.php | 50 ++++++++++++++++++++++++++
 tests/Feature/Orders/OrderServiceTest.php      |  6 +++-
 5 files changed, 117 insertions(+), 7 deletions(-)


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


output:
Chunk ID: 1f7e71
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 97
Output:
 app/Services/DiscountService.php               | 45 +++++++++++++++++++++++
 app/Services/OrderService.php                  | 11 ++++--
 specs/progress.md                              | 12 ++++---
 tests/Feature/Checkout/PricingServicesTest.php | 50 ++++++++++++++++++++++++++
 tests/Feature/Orders/OrderServiceTest.php      |  6 +++-
 5 files changed, 117 insertions(+), 7 deletions(-)


command:
/bin/zsh
-lc
git status --short

cmd: git status --short

aggregated_output:
 M app/Services/DiscountService.php
 M app/Services/OrderService.php
 M specs/progress.md
 M tests/Feature/Checkout/PricingServicesTest.php
 M tests/Feature/Orders/OrderServiceTest.php


output:
Chunk ID: 3e3df1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
 M app/Services/DiscountService.php
 M app/Services/OrderService.php
 M specs/progress.md
 M tests/Feature/Checkout/PricingServicesTest.php
 M tests/Feature/Orders/OrderServiceTest.php


step: Inspect discount spec, order schema, and existing DiscountService/tests

step: Implement customer usage-history enforcement for one_per_customer discounts

step: Add focused pricing/order tests for redemption enforcement

step: Run formatter and affected/full tests

step: Update progress log and commit the discount rule slice


output: Plan updated


message: The discount slice is verified and the progress log is updated. I’m staging and committing this as its own backend rules change.

phase: commentary


type: message

role: assistant

text: The discount slice is verified and the progress log is updated. I’m staging and committing this as its own backend rules change.


cmd: git add app/Services/DiscountService.php app/Services/OrderService.php tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php specs/progress.md && git diff --cached --stat && git commit -m "Enforce one-use customer discounts"


command: git add app/Services/DiscountService.php app/Services/OrderService.php tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php specs/progress.md && git diff --cached --stat && git commit -m "Enforce one-use customer discounts"

aggregated_output:
 app/Services/DiscountService.php               | 45 +++++++++++++++++++++++
 app/Services/OrderService.php                  | 11 ++++--
 specs/progress.md                              | 12 ++++---
 tests/Feature/Checkout/PricingServicesTest.php | 50 ++++++++++++++++++++++++++
 tests/Feature/Orders/OrderServiceTest.php      |  6 +++-
 5 files changed, 117 insertions(+), 7 deletions(-)
[2026-05-04-codex-5-5-goal fc4c154e] Enforce one-use customer discounts
 5 files changed, 117 insertions(+), 7 deletions(-)


output:
Chunk ID: 0fb3c3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 128
Output:
 app/Services/DiscountService.php               | 45 +++++++++++++++++++++++
 app/Services/OrderService.php                  | 11 ++++--
 specs/progress.md                              | 12 ++++---
 tests/Feature/Checkout/PricingServicesTest.php | 50 ++++++++++++++++++++++++++
 tests/Feature/Orders/OrderServiceTest.php      |  6 +++-
 5 files changed, 117 insertions(+), 7 deletions(-)
[2026-05-04-codex-5-5-goal fc4c154e] Enforce one-use customer discounts
 5 files changed, 117 insertions(+), 7 deletions(-)


output:
Chunk ID: 60b6d7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


message: Committed `fc4c154e Enforce one-use customer discounts`; the worktree is clean. Next bounded gap: search synonyms and stop words are stored in admin settings but not applied during storefront FTS query parsing.


text: Committed `fc4c154e Enforce one-use customer discounts`; the worktree is clean. Next bounded gap: search synonyms and stop words are stored in admin settings but not applied during storefront FTS query parsing.


step: Inspect search settings spec, SearchService query parsing, and tests

step: Apply per-store stop words and synonym expansion in search queries

status: pending

step: Add focused search tests for synonyms/stop words

step: Update progress log and commit the search parsing slice

explanation: Starting the next completion gap: use stored search synonyms and stop words during SQLite FTS search.


output: Plan updated


message: Using `laravel-best-practices` and `pest-testing` for this search-service backend/test slice, with Boost docs refreshed before code changes.


text: Using `laravel-best-practices` and `pest-testing` for this search-service backend/test slice, with Boost docs refreshed before code changes.


packages: laravel/framework pestphp/pest

queries: sqlite full text search query builder raw bindings


cmd: rg -n "synonym|stop word|stop_words|synonyms|SearchService|products_fts|search settings" specs/05-BUSINESS-LOGIC.md specs/03-ADMIN-UI.md specs/09-IMPLEMENTATION-ROADMAP.md app tests database -g '*.php'


cmd: sed -n '1,460p' app/Services/SearchService.php


cmd: sed -n '1,260p' app/Models/SearchSettings.php


cmd: sed -n '1,260p' tests/Feature/Search/SearchServiceTest.php


cmd: sed -n '1,260p' app/Livewire/Admin/Search/Settings.php


server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Database Introduction Configuration SQLite Configuration
Database | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md

#### SQLite Configuration

SQLite databases are contained within a single file on your filesystem. You can create a new SQLite database using the `touch` command in your terminal: `touch database/database.sqlite`. After the database has been created, you may easily configure your environment variables to point to this database by placing the absolute path to the database in the `DB_DATABASE` environment variable:

```ini
DB_CONNECTION=sqlite
DB_DATABASE=/absolute/path/to/database.sqlite
```

By default, foreign key constraints are enabled for SQLite connections. If you would like to disable them, you should set the `DB_FOREIGN_KEYS` environment variable to `false`:

```ini
DB_FOREIGN_KEYS=false
```

> [!NOTE]
> If you use the [Laravel installer](/docs/{{version}}/installation#creating-a-laravel-project) to create your Laravel application and select SQLite as your database, Laravel will automatically create a `database/database.sqlite` file and run the default [database migrations](/docs/{{version}}/migrations) for you.

---

## Scout Database / Collection Engines Collection Engine
Scout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md

### Collection Engine

The "collection" engine is intended for quick prototypes, extremely small datasets (a few hundred records), or running tests. It retrieves all possible records from your database and uses Laravel's `Str::is` helper to filter them in PHP, so it does not require any indexing or database-specific features. For anything beyond trivial use cases, you should use the [database engine](#database-engine) instead.

To use the collection engine, you may simply set the value of the `SCOUT_DRIVER` environment variable to `collection`, or specify the `collection` driver directly in your application's `scout` configuration file:

```ini
SCOUT_DRIVER=collection
```

Once you have specified the collection driver as your preferred driver, you may start [executing search queries](#searching) against your models. Search engine indexing, such as the indexing needed to seed Algolia, Meilisearch, or Typesense indexes, is unnecessary when using the collection engine.

#### Differences From Database Engine

While the database engine uses full-text indexes and `LIKE` clauses to find matching records efficiently, the collection engine pulls all records and filters them in PHP. The collection engine is the most portable option as it works across all relational databases supported by Laravel (including SQLite and SQL Server); however, it is significantly less efficient than the database engine and should not be used with large datasets.

---

## Strings Fluent Strings `wrap` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `wrap` {.collection-method}

The `wrap` method wraps the given string with an additional string or pair of strings:

```php
use Illuminate\Support\Str;

Str::of('Laravel')->wrap('"');

// "Laravel"

Str::is('is')->wrap(before: 'This ', after: ' Laravel!');

// This is Laravel!
```

---

## Pennant Adding Custom Pennant Drivers Defining Features Externally
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

### Defining Features Externally

If your driver is a wrapper around a third-party feature flag platform, you will likely define features on the platform rather than using Pennant's `Feature::define` method. If that is the case, your custom driver should also implement the `Laravel\Pennant\Contracts\DefinesFeaturesExternally` interface:

```php
<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;
use Laravel\Pennant\Contracts\DefinesFeaturesExternally;

class FeatureFlagServiceDriver implements Driver, DefinesFeaturesExternally
{
    /**
     * Get the features defined for the given scope.
     */
    public function definedFeaturesForScope(mixed $scope): array {}

    /* ... */
}
```

The `definedFeaturesForScope` method should return a list of feature names defined for the provided scope.

---

## Strings Fluent Strings `words` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `words` {.collection-method}

The `words` method limits the number of words in a string. If necessary, you may specify an additional string that will be appended to the truncated string:

```php
use Illuminate\Support\Str;

$string = Str::of('Perfectly balanced, as all things should be.')->words(3, ' >>>');

// Perfectly balanced, as >>>
```

---

## Scout Database / Collection Engines
Scout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md

## Database / Collection Engines

---

## Pennant Checking Features In-Memory Cache
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

### In-Memory Cache

When checking a feature, Pennant will create an in-memory cache of the result. If you are using the `database` driver, this means that re-checking the same feature flag within a single request will not trigger additional database queries. This also ensures that the feature has a consistent result for the duration of the request.

If you need to manually flush the in-memory cache, you may use the `flushCache` method offered by the `Feature` facade:

```php
Feature::flushCache();
```

---

## Pennant Updating Values Purging Features
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

### Purging Features

Sometimes, it can be useful to purge an entire feature from storage. This is typically necessary if you have removed the feature from your application or you have made adjustments to the feature's definition that you would like to rollout to all users.

You may remove all stored values for a feature using the `purge` method:

```php
// Purging a single feature...
Feature::purge('new-api');

// Purging multiple features...
Feature::purge(['new-api', 'purchase-button']);
```

If you would like to purge _all_ features from storage, you may invoke the `purge` method without any arguments:

```php
Feature::purge();
```

As it can be useful to purge features as part of your application's deployment pipeline, Pennant includes a `pennant:purge` Artisan command which will purge the provided features from storage:

```shell
php artisan pennant:purge new-api

php artisan pennant:purge new-api purchase-button
```

It is also possible to purge all features _except_ those in a given feature list. For example, imagine you wanted to purge all features but keep the values for the "new-api" and "purchase-button" features in storage. To accomplish this, you can pass those feature names to the `--except` option:

```shell
php artisan pennant:purge --except=new-api --except=purchase-button
```

For convenience, the `pennant:purge` command also supports an `--except-registered` flag. This flag indicates that all features except those explicitly registered in a service provider should be purged:

```shell
php artisan pennant:purge --except-registered
```

---

## Strings Fluent Strings `wordCount` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `wordCount` {.collection-method}

The `wordCount` method returns the number of words that a string contains:

```php
use Illuminate\Support\Str;

Str::of('Hello, world!')->wordCount(); // 2
```

---

## Pennant Retrieving Multiple Features
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

## Retrieving Multiple Features

The `values` method allows the retrieval of multiple features for a given scope:

```php
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
```

Or, you may use the `all` method to retrieve the values of all defined features for a given scope:

```php
Feature::all();

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]
```

However, class-based features are dynamically registered and are not known by Pennant until they are explicitly checked. This means your application's class-based features may not appear in the results returned by the `all` method if they have not already been checked during the current request.

If you would like to ensure that feature classes are always included when using the `all` method, you may use Pennant's feature discovery capabilities. To get started, invoke the `discover` method in one of your application's service providers:

```php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::discover();

        // ...
    }
}
```

The `discover` method will register all of the feature classes in your application's `app/Features` directory. The `all` method will now include these classes in its results, regardless of whether they have been checked during the current request:

```php
Feature::all();

// [
//     'App\Features\NewApi' => true,
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]
```

---

## Strings Fluent Strings `whenIsUuid` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `whenIsUuid` {.collection-method}

The `whenIsUuid` method invokes the given closure if the string is a valid UUID. The closure will receive the fluent string instance:

```php
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;

$string = Str::of('a0a2a2d2-0b87-4a18-83f2-2529882be2de')->whenIsUuid(function (Stringable $string) {
    return $string->substr(0, 8);
});

// 'a0a2a2d2'
```

---

## Pennant Rich Feature Values
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

## Rich Feature Values

Until now, we have primarily shown features as being in a binary state, meaning they are either "active" or "inactive", but Pennant also allows you to store rich values as well.

For example, imagine you are testing three new colors for the "Buy now" button of your application. Instead of returning `true` or `false` from the feature definition, you may instead return a string:

```php
use Illuminate\Support\Arr;
use Laravel\Pennant\Feature;

Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
```

You may retrieve the value of the `purchase-button` feature using the `value` method:

```php
$color = Feature::value('purchase-button');
```

Pennant's included Blade directive also makes it easy to conditionally render content based on the current value of the feature:

```blade
@feature('purchase-button', 'blue-sapphire')
    <!-- 'blue-sapphire' is active -->
@elsefeature('purchase-button', 'seafoam-green')
    <!-- 'seafoam-green' is active -->
@elsefeature('purchase-button', 'tart-orange')
    <!-- 'tart-orange' is active -->
@endfeature
```

> [!NOTE]
> When using rich values, it is important to know that a feature is considered "active" when it has any value other than `false`.

When calling the [conditional `when`](#conditional-execution) method, the feature's rich value will be provided to the first closure:

```php
Feature::when('purchase-button',
    fn ($color) => /* ... */,
    fn () => /* ... */,
);
```

Likewise, when calling the conditional `unless` method, the feature's rich value will be provided to the optional second closure:

```php
Feature::unless('purchase-button',
    fn () => /* ... */,
    fn ($color) => /* ... */,
);
```

---

## Pennant Checking Features The `HasFeatures` Trait
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

### The `HasFeatures` Trait

Pennant's `HasFeatures` trait may be added to your application's `User` model (or any other model that has features) to provide a fluent, convenient way to check features directly from the model:

```php
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;

    // ...
}
```

Once the trait has been added to your model, you may easily check features by invoking the `features` method:

```php
if ($user->features()->active('new-api')) {
    // ...
}
```

Of course, the `features` method provides access to many other convenient methods for interacting with features:

```php
// Values...
$value = $user->features()->value('purchase-button')
$values = $user->features()->values(['new-api', 'purchase-button']);

// State...
$user->features()->active('new-api');
$user->features()->allAreActive(['new-api', 'server-api']);
$user->features()->someAreActive(['new-api', 'server-api']);

$user->features()->inactive('new-api');
$user->features()->allAreInactive(['new-api', 'server-api']);
$user->features()->someAreInactive(['new-api', 'server-api']);

// Conditional execution...
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

$user->features()->unless('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);
```

---

## Pennant Checking Features Intercepting Feature Checks
Pennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md

### Intercepting Feature Checks

Sometimes it can be useful to perform some in-memory checks before retrieving the stored value of a given feature. Imagine you are developing a new API behind a feature flag and want the ability to disable the new API without losing any of the resolved feature values in storage. If you notice a bug in the new API, you could easily disable it for everyone except internal team members, fix the bug, and then re-enable the new API for the users that previously had access to the feature.

You can achieve this with a [class-based feature's](#class-based-features) `before` method. When present, the `before` method is always run in-memory before retrieving the value from storage. If a non-`null` value is returned from the method, it will be used in place of the feature's stored value for the duration of the request:

```php
<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * Run an always-in-memory check before the stored value is retrieved.
     */
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}
```

You could also use this feature to schedule the global rollout of a feature that was previously behind a feature flag:

```php
<?php

namespace App\Features;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;

class NewApi
{
    /**
     * Run an always-in-memory check before the stored value is retrieved.
     */
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }

        if (Carbon::parse(Config::get('features.new-api.rollout-date'))->isPast()) {
            return true;
        }
    }

    // ...
}
```

_[Truncated]_


output:
Wall time: 3.2661 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Database Introduction Configuration SQLite Configuration\nDatabase | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md\n\n\n#### SQLite Configuration\n\nSQLite databases are contained within a single file on your filesystem. You can create a new SQLite database using the `touch` command in your terminal: `touch database/database.sqlite`. After the database has been created, you may easily configure your environment variables to point to this database by placing the absolute path to the database in the `DB_DATABASE` environment variable:\n\n```ini\nDB_CONNECTION=sqlite\nDB_DATABASE=/absolute/path/to/database.sqlite\n```\n\nBy default, foreign key constraints are enabled for SQLite connections. If you would like to disable them, you should set the `DB_FOREIGN_KEYS` environment variable to `false`:\n\n```ini\nDB_FOREIGN_KEYS=false\n```\n\n> [!NOTE]\n> If you use the [Laravel installer](/docs/{{version}}/installation#creating-a-laravel-project) to create your Laravel application and select SQLite as your database, Laravel will automatically create a `database/database.sqlite` file and run the default [database migrations](/docs/{{version}}/migrations) for you.\n\n---\n\n## Scout Database / Collection Engines Collection Engine\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n### Collection Engine\n\nThe \"collection\" engine is intended for quick prototypes, extremely small datasets (a few hundred records), or running tests. It retrieves all possible records from your database and uses Laravel's `Str::is` helper to filter them in PHP, so it does not require any indexing or database-specific features. For anything beyond trivial use cases, you should use the [database engine](#database-engine) instead.\n\nTo use the collection engine, you may simply set the value of the `SCOUT_DRIVER` environment variable to `collection`, or specify the `collection` driver directly in your application's `scout` configuration file:\n\n```ini\nSCOUT_DRIVER=collection\n```\n\nOnce you have specified the collection driver as your preferred driver, you may start [executing search queries](#searching) against your models. Search engine indexing, such as the indexing needed to seed Algolia, Meilisearch, or Typesense indexes, is unnecessary when using the collection engine.\n\n#### Differences From Database Engine\n\nWhile the database engine uses full-text indexes and `LIKE` clauses to find matching records efficiently, the collection engine pulls all records and filters them in PHP. The collection engine is the most portable option as it works across all relational databases supported by Laravel (including SQLite and SQL Server); however, it is significantly less efficient than the database engine and should not be used with large datasets.\n\n---\n\n## Strings Fluent Strings `wrap` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `wrap` {.collection-method}\n\nThe `wrap` method wraps the given string with an additional string or pair of strings:\n\n```php\nuse Illuminate\\Support\\Str;\n\nStr::of('Laravel')->wrap('\"');\n\n// \"Laravel\"\n\nStr::is('is')->wrap(before: 'This ', after: ' Laravel!');\n\n// This is Laravel!\n```\n\n---\n\n## Pennant Adding Custom Pennant Drivers Defining Features Externally\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n### Defining Features Externally\n\nIf your driver is a wrapper around a third-party feature flag platform, you will likely define features on the platform rather than using Pennant's `Feature::define` method. If that is the case, your custom driver should also implement the `Laravel\\Pennant\\Contracts\\DefinesFeaturesExternally` interface:\n\n```php\n<?php\n\nnamespace App\\Extensions;\n\nuse Laravel\\Pennant\\Contracts\\Driver;\nuse Laravel\\Pennant\\Contracts\\DefinesFeaturesExternally;\n\nclass FeatureFlagServiceDriver implements Driver, DefinesFeaturesExternally\n{\n    /**\n     * Get the features defined for the given scope.\n     */\n    public function definedFeaturesForScope(mixed $scope): array {}\n\n    /* ... */\n}\n```\n\nThe `definedFeaturesForScope` method should return a list of feature names defined for the provided scope.\n\n---\n\n## Strings Fluent Strings `words` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `words` {.collection-method}\n\nThe `words` method limits the number of words in a string. If necessary, you may specify an additional string that will be appended to the truncated string:\n\n```php\nuse Illuminate\\Support\\Str;\n\n$string = Str::of('Perfectly balanced, as all things should be.')->words(3, ' >>>');\n\n// Perfectly balanced, as >>>\n```\n\n---\n\n## Scout Database / Collection Engines\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n## Database / Collection Engines\n\n---\n\n## Pennant Checking Features In-Memory Cache\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n### In-Memory Cache\n\nWhen checking a feature, Pennant will create an in-memory cache of the result. If you are using the `database` driver, this means that re-checking the same feature flag within a single request will not trigger additional database queries. This also ensures that the feature has a consistent result for the duration of the request.\n\nIf you need to manually flush the in-memory cache, you may use the `flushCache` method offered by the `Feature` facade:\n\n```php\nFeature::flushCache();\n```\n\n---\n\n## Pennant Updating Values Purging Features\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n### Purging Features\n\nSometimes, it can be useful to purge an entire feature from storage. This is typically necessary if you have removed the feature from your application or you have made adjustments to the feature's definition that you would like to rollout to all users.\n\nYou may remove all stored values for a feature using the `purge` method:\n\n```php\n// Purging a single feature...\nFeature::purge('new-api');\n\n// Purging multiple features...\nFeature::purge(['new-api', 'purchase-button']);\n```\n\nIf you would like to purge _all_ features from storage, you may invoke the `purge` method without any arguments:\n\n```php\nFeature::purge();\n```\n\nAs it can be useful to purge features as part of your application's deployment pipeline, Pennant includes a `pennant:purge` Artisan command which will purge the provided features from storage:\n\n```shell\nphp artisan pennant:purge new-api\n\nphp artisan pennant:purge new-api purchase-button\n```\n\nIt is also possible to purge all features _except_ those in a given feature list. For example, imagine you wanted to purge all features but keep the values for the \"new-api\" and \"purchase-button\" features in storage. To accomplish this, you can pass those feature names to the `--except` option:\n\n```shell\nphp artisan pennant:purge --except=new-api --except=purchase-button\n```\n\nFor convenience, the `pennant:purge` command also supports an `--except-registered` flag. This flag indicates that all features except those explicitly registered in a service provider should be purged:\n\n```shell\nphp artisan pennant:purge --except-registered\n```\n\n---\n\n## Strings Fluent Strings `wordCount` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `wordCount` {.collection-method}\n\nThe `wordCount` method returns the number of words that a string contains:\n\n```php\nuse Illuminate\\Support\\Str;\n\nStr::of('Hello, world!')->wordCount(); // 2\n```\n\n---\n\n## Pennant Retrieving Multiple Features\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n## Retrieving Multiple Features\n\nThe `values` method allows the retrieval of multiple features for a given scope:\n\n```php\nFeature::values(['billing-v2', 'purchase-button']);\n\n// [\n//     'billing-v2' => false,\n//     'purchase-button' => 'blue-sapphire',\n// ]\n```\n\nOr, you may use the `all` method to retrieve the values of all defined features for a given scope:\n\n```php\nFeature::all();\n\n// [\n//     'billing-v2' => false,\n//     'purchase-button' => 'blue-sapphire',\n//     'site-redesign' => true,\n// ]\n```\n\nHowever, class-based features are dynamically registered and are not known by Pennant until they are explicitly checked. This means your application's class-based features may not appear in the results returned by the `all` method if they have not already been checked during the current request.\n\nIf you would like to ensure that feature classes are always included when using the `all` method, you may use Pennant's feature discovery capabilities. To get started, invoke the `discover` method in one of your application's service providers:\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\ServiceProvider;\nuse Laravel\\Pennant\\Feature;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Feature::discover();\n\n        // ...\n    }\n}\n```\n\nThe `discover` method will register all of the feature classes in your application's `app/Features` directory. The `all` method will now include these classes in its results, regardless of whether they have been checked during the current request:\n\n```php\nFeature::all();\n\n// [\n//     'App\\Features\\NewApi' => true,\n//     'billing-v2' => false,\n//     'purchase-button' => 'blue-sapphire',\n//     'site-redesign' => true,\n// ]\n```\n\n---\n\n## Strings Fluent Strings `whenIsUuid` {.collection-method}\nStrings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md\n\n\n#### `whenIsUuid` {.collection-method}\n\nThe `whenIsUuid` method invokes the given closure if the string is a valid UUID. The closure will receive the fluent string instance:\n\n```php\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Support\\Stringable;\n\n$string = Str::of('a0a2a2d2-0b87-4a18-83f2-2529882be2de')->whenIsUuid(function (Stringable $string) {\n    return $string->substr(0, 8);\n});\n\n// 'a0a2a2d2'\n```\n\n---\n\n## Pennant Rich Feature Values\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n## Rich Feature Values\n\nUntil now, we have primarily shown features as being in a binary state, meaning they are either \"active\" or \"inactive\", but Pennant also allows you to store rich values as well.\n\nFor example, imagine you are testing three new colors for the \"Buy now\" button of your application. Instead of returning `true` or `false` from the feature definition, you may instead return a string:\n\n```php\nuse Illuminate\\Support\\Arr;\nuse Laravel\\Pennant\\Feature;\n\nFeature::define('purchase-button', fn (User $user) => Arr::random([\n    'blue-sapphire',\n    'seafoam-green',\n    'tart-orange',\n]));\n```\n\nYou may retrieve the value of the `purchase-button` feature using the `value` method:\n\n```php\n$color = Feature::value('purchase-button');\n```\n\nPennant's included Blade directive also makes it easy to conditionally render content based on the current value of the feature:\n\n```blade\n@feature('purchase-button', 'blue-sapphire')\n    <!-- 'blue-sapphire' is active -->\n@elsefeature('purchase-button', 'seafoam-green')\n    <!-- 'seafoam-green' is active -->\n@elsefeature('purchase-button', 'tart-orange')\n    <!-- 'tart-orange' is active -->\n@endfeature\n```\n\n> [!NOTE]\n> When using rich values, it is important to know that a feature is considered \"active\" when it has any value other than `false`.\n\nWhen calling the [conditional `when`](#conditional-execution) method, the feature's rich value will be provided to the first closure:\n\n```php\nFeature::when('purchase-button',\n    fn ($color) => /* ... */,\n    fn () => /* ... */,\n);\n```\n\nLikewise, when calling the conditional `unless` method, the feature's rich value will be provided to the optional second closure:\n\n```php\nFeature::unless('purchase-button',\n    fn () => /* ... */,\n    fn ($color) => /* ... */,\n);\n```\n\n---\n\n## Pennant Checking Features The `HasFeatures` Trait\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n### The `HasFeatures` Trait\n\nPennant's `HasFeatures` trait may be added to your application's `User` model (or any other model that has features) to provide a fluent, convenient way to check features directly from the model:\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse Illuminate\\Foundation\\Auth\\User as Authenticatable;\nuse Laravel\\Pennant\\Concerns\\HasFeatures;\n\nclass User extends Authenticatable\n{\n    use HasFeatures;\n\n    // ...\n}\n```\n\nOnce the trait has been added to your model, you may easily check features by invoking the `features` method:\n\n```php\nif ($user->features()->active('new-api')) {\n    // ...\n}\n```\n\nOf course, the `features` method provides access to many other convenient methods for interacting with features:\n\n```php\n// Values...\n$value = $user->features()->value('purchase-button')\n$values = $user->features()->values(['new-api', 'purchase-button']);\n\n// State...\n$user->features()->active('new-api');\n$user->features()->allAreActive(['new-api', 'server-api']);\n$user->features()->someAreActive(['new-api', 'server-api']);\n\n$user->features()->inactive('new-api');\n$user->features()->allAreInactive(['new-api', 'server-api']);\n$user->features()->someAreInactive(['new-api', 'server-api']);\n\n// Conditional execution...\n$user->features()->when('new-api',\n    fn () => /* ... */,\n    fn () => /* ... */,\n);\n\n$user->features()->unless('new-api',\n    fn () => /* ... */,\n    fn () => /* ... */,\n);\n```\n\n---\n\n## Pennant Checking Features Intercepting Feature Checks\nPennant | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pennant.md\n\n\n### Intercepting Feature Checks\n\nSometimes it can be useful to perform some in-memory checks before retrieving the stored value of a given feature. Imagine you are developing a new API behind a feature flag and want the ability to disable the new API without losing any of the resolved feature values in storage. If you notice a bug in the new API, you could easily disable it for everyone except internal team members, fix the bug, and then re-enable the new API for the users that previously had access to the feature.\n\nYou can achieve this with a [class-based feature's](#class-based-features) `before` method. When present, the `before` method is always run in-memory before retrieving the value from storage. If a non-`null` value is returned from the method, it will be used in place of the feature's stored value for the duration of the request:\n\n```php\n<?php\n\nnamespace App\\Features;\n\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Lottery;\n\nclass NewApi\n{\n    /**\n     * Run an always-in-memory check before the stored value is retrieved.\n     */\n    public function before(User $user): mixed\n    {\n        if (Config::get('features.new-api.disabled')) {\n            return $user->isInternalTeamMember();\n        }\n    }\n\n    /**\n     * Resolve the feature's initial value.\n     */\n    public function resolve(User $user): mixed\n    {\n        return match (true) {\n            $user->isInternalTeamMember() => true,\n            $user->isHighTrafficCustomer() => false,\n            default => Lottery::odds(1 / 100),\n        };\n    }\n}\n```\n\nYou could also use this feature to schedule the global rollout of a feature that was previously behind a feature flag:\n\n```php\n<?php\n\nnamespace App\\Features;\n\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Config;\n\nclass NewApi\n{\n    /**\n     * Run an always-in-memory check before the stored value is retrieved.\n     */\n    public function before(User $user): mixed\n    {\n        if (Config::get('features.new-api.disabled')) {\n            return $user->isInternalTeamMember();\n        }\n\n        if (Carbon::parse(Config::get('features.new-api.rollout-date'))->isPast()) {\n            return true;\n        }\n    }\n\n    // ...\n}\n```\n\n_[Truncated]_"}]


command: sed -n '1,260p' app/Livewire/Admin/Search/Settings.php

Settings.php

type: read

aggregated_output:
<?php

namespace App\Livewire\Admin\Search;

use App\Models\SearchSettings;
use App\Models\Store;
use App\Services\SearchService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Settings extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

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

    public string $stopWords = '';

    public ?string $lastIndexedAt = null;

    public bool $isReindexing = false;

    public ?int $reindexProgress = null;

    public function mount(): void
    {
        $store = $this->store();

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

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

        $settings = $this->settings();
        $this->synonymGroups = collect($settings->synonyms_json ?? [])
            ->map(fn (array $group): string => implode(', ', $group))
            ->values()
            ->all();
        $this->stopWords = implode(', ', $settings->stop_words_json ?? []);
        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();
    }

    public function addSynonymGroup(): void
    {
        $this->synonymGroups[] = '';
    }

    public function removeSynonymGroup(int $index): void
    {
        unset($this->synonymGroups[$index]);
        $this->synonymGroups = array_values($this->synonymGroups);
    }

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

        $this->validate([
            'synonymGroups' => ['array', 'max:50'],
            'synonymGroups.*' => ['nullable', 'string', 'max:500'],
            'stopWords' => ['nullable', 'string', 'max:2000'],
        ], [], [
            'synonymGroups.*' => 'synonym group',
            'stopWords' => 'stop words',
        ]);

        $settings = $this->settings();
        $settings->forceFill([
            'synonyms_json' => $this->parseSynonyms(),
            'stop_words_json' => $this->parseWordList($this->stopWords),
        ])->save();

        $this->lastIndexedAt = $settings->updated_at?->toDayDateTimeString();

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

    public function triggerReindex(SearchService $search): void
    {
        $store = $this->scopedStore();

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

        $this->isReindexing = true;
        $this->reindexProgress = 10;

        $count = $search->reindex($store);

        $settings = $this->settings();
        $settings->touch();

        $this->lastIndexedAt = $settings->refresh()->updated_at?->toDayDateTimeString();
        $this->reindexProgress = 100;
        $this->isReindexing = false;

        session()->flash('status', __('Search index rebuilt for :count products', ['count' => $count]));
        $this->dispatch('toast', type: 'success', message: __('Search index rebuilt'));
    }

    public function pollReindexStatus(): void
    {
        $this->reindexProgress = $this->isReindexing ? $this->reindexProgress : null;
    }

    public function render(): mixed
    {
        return view('livewire.admin.search.settings')->layout('layouts.app', [
            'title' => __('Search settings'),
        ]);
    }

    /**
     * @return list<list<string>>
     */
    private function parseSynonyms(): array
    {
        return collect($this->synonymGroups)
            ->map(fn (string $group): array => $this->parseWordList($group))
            ->filter(fn (array $group): bool => count($group) >= 2)
            ->values()
            ->all();
    }

    /**
     * @return list<string>
     */
    private function parseWordList(string $words): array
    {
        return collect(explode(',', mb_strtolower($words)))
            ->map(fn (string $word): string => trim($word))
            ->filter()
            ->unique()
            ->values()
            ->all();
    }

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

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->findOrFail($this->storeId);
    }
}


command: sed -n '1,460p' app/Services/SearchService.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Enums\ProductStatus;
use App\Models\Collection as ProductCollection;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\SearchQuery;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class SearchService
{
    /**
     * @param  array<string, mixed>  $filters
     */
    public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator
    {
        $query = trim($query);
        $perPage = max(1, min(50, $perPage));

        if ($query === '') {
            return $this->browse($store, $filters, $perPage, $sort);
        }

        $paginator = $this->baseSearchQuery($store, $query, $filters)
            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, true))
            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());

        $paginator->setCollection($this->hydrateProducts(
            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
        ));

        $this->log($store, $query, $filters, $paginator->total());

        return $paginator;
    }

    public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection
    {
        $prefix = trim($prefix);
        $limit = max(1, min(10, $limit));

        if ($prefix === '') {
            return collect();
        }

        $ids = $this->baseSearchQuery($store, $prefix, [])
            ->orderByRaw('products_fts.rank')
            ->limit($limit)
            ->pluck('products.id')
            ->map(fn (mixed $id): int => (int) $id)
            ->all();

        return $this->hydrateProducts($ids);
    }

    public function suggestions(Store $store, string $prefix, int $limit = 5): Collection
    {
        $productLimit = max(1, min(10, $limit));

        $products = $this->autocomplete($store, $prefix, $productLimit)
            ->map(function (Product $product): array {
                $variant = $product->variants->first();

                return [
                    'type' => 'product',
                    'title' => $product->title,
                    'handle' => $product->handle,
                    'image_url' => $product->media->first()?->storage_key,
                    'price_amount' => $variant?->price_amount,
                    'currency' => $variant?->currency ?? $product->store?->default_currency,
                ];
            });

        $remaining = max(0, $productLimit - $products->count());

        $collections = $remaining > 0
            ? ProductCollection::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('status', 'active')
                ->where('title', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], trim($prefix)).'%')
                ->oldest('title')
                ->limit($remaining)
                ->get()
                ->map(fn (ProductCollection $collection): array => [
                    'type' => 'collection',
                    'title' => $collection->title,
                    'handle' => $collection->handle,
                    'image_url' => null,
                ])
            : collect();

        return $products->concat($collections)->values();
    }

    /**
     * @param  array<string, mixed>  $filters
     * @return array{vendors: list<array{value: string, count: int}>, tags: list<array{value: string, count: int}>, price_range: array{min: int|null, max: int|null}}
     */
    public function facets(Store $store, string $query, array $filters = []): array
    {
        $ids = trim($query) === ''
            ? $this->activeProductQuery($store, $filters)->limit(1000)->pluck('products.id')->all()
            : $this->baseSearchQuery($store, $query, $filters)->limit(1000)->pluck('products.id')->all();

        $ids = collect($ids)->map(fn (mixed $id): int => (int) $id)->all();

        if ($ids === []) {
            return [
                'vendors' => [],
                'tags' => [],
                'price_range' => ['min' => null, 'max' => null],
            ];
        }

        $vendors = Product::withoutGlobalScopes()
            ->whereIn('id', $ids)
            ->whereNotNull('vendor')
            ->selectRaw('vendor as value, COUNT(*) as count')
            ->groupBy('vendor')
            ->orderBy('vendor')
            ->get()
            ->map(fn (Product $product): array => [
                'value' => (string) $product->value,
                'count' => (int) $product->count,
            ])
            ->all();

        $tags = Product::withoutGlobalScopes()
            ->whereIn('id', $ids)
            ->get(['tags'])
            ->flatMap(fn (Prod…4756 chars truncated…ixed $vendor): string => (string) $vendor)
            ->values()
            ->all();

        if ($vendors !== []) {
            $builder->whereIn('products.vendor', $vendors);
        }

        $productTypes = collect(Arr::wrap($filters['product_type'] ?? []))
            ->filter(fn (mixed $productType): bool => filled($productType))
            ->map(fn (mixed $productType): string => (string) $productType)
            ->values()
            ->all();

        if ($productTypes !== []) {
            $builder->whereIn('products.product_type', $productTypes);
        }

        foreach (Arr::wrap($filters['tags'] ?? []) as $tag) {
            if (filled($tag)) {
                $builder->where('products.tags', 'like', '%"'.str_replace(['%', '_'], ['\%', '\_'], (string) $tag).'"%');
            }
        }

        if (isset($filters['price_min']) || isset($filters['price_max'])) {
            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
                $query
                    ->selectRaw('1')
                    ->from('product_variants')
                    ->whereColumn('product_variants.product_id', 'products.id');

                if (isset($filters['price_min'])) {
                    $query->where('product_variants.price_amount', '>=', (int) $filters['price_min']);
                }

                if (isset($filters['price_max'])) {
                    $query->where('product_variants.price_amount', '<=', (int) $filters['price_max']);
                }
            });
        }

        if (filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOL)) {
            $builder->whereExists(function (QueryBuilder $query): void {
                $query
                    ->selectRaw('1')
                    ->from('product_variants')
                    ->join('inventory_items', 'inventory_items.variant_id', '=', 'product_variants.id')
                    ->whereColumn('product_variants.product_id', 'products.id')
                    ->where(function (QueryBuilder $query): void {
                        $query
                            ->whereRaw('inventory_items.quantity_on_hand > inventory_items.quantity_reserved')
                            ->orWhere('inventory_items.policy', InventoryPolicy::Continue->value);
                    });
            });
        }
    }

    private function applySort(QueryBuilder $builder, string $sort, bool $hasRank): void
    {
        match ($sort) {
            'price_asc' => $builder
                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC')
                ->orderBy('products.id'),
            'price_desc' => $builder
                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC')
                ->orderBy('products.id'),
            'newest' => $builder
                ->orderByDesc('products.published_at')
                ->orderByDesc('products.id'),
            'best_selling' => $builder
                ->orderByRaw('(SELECT COALESCE(SUM(order_lines.quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC')
                ->orderByDesc('products.published_at')
                ->orderByDesc('products.id'),
            default => $hasRank
                ? $builder->orderByRaw('products_fts.rank')
                : $builder->orderByDesc('products.published_at')->orderByDesc('products.id'),
        };
    }

    /**
     * @param  list<int>  $ids
     * @return Collection<int, Product>
     */
    private function hydrateProducts(array $ids): Collection
    {
        if ($ids === []) {
            return collect();
        }

        $products = Product::withoutGlobalScopes()
            ->with(['store', 'variants.inventoryItem', 'media'])
            ->withCount('variants')
            ->whereIn('id', $ids)
            ->get()
            ->keyBy('id');

        return collect($ids)
            ->map(fn (int $id): ?Product => $products->get($id))
            ->filter()
            ->values();
    }

    private function toFtsQuery(string $query): string
    {
        $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $tokens = array_slice($tokens, 0, 8);
        $lastIndex = count($tokens) - 1;

        return collect($tokens)
            ->map(fn (string $token, int $index): string => $index === $lastIndex ? "{$token}*" : $token)
            ->implode(' ');
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function log(Store $store, string $query, array $filters, int $resultsCount): void
    {
        SearchQuery::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'query' => mb_substr($query, 0, 200),
            'filters_json' => $filters,
            'results_count' => $resultsCount,
            'created_at' => now(),
        ]);
    }
}


command: sed -n '1,260p' app/Models/SearchSettings.php

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


command: sed -n '1,260p' tests/Feature/Search/SearchServiceTest.php

aggregated_output:
<?php

use App\Models\Product;
use App\Models\SearchQuery;
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);
});


command: rg -n "synonym|stop word|stop_words|synonyms|SearchService|products_fts|search settings" specs/05-BUSINESS-LOGIC.md specs/03-ADMIN-UI.md specs/09-IMPLEMENTATION-ROADMAP.md app tests database -g '*.php'

type: search

cmd: rg -n 'synonym|stop word|stop_words|synonyms|SearchService|products_fts|search settings' specs/05-BUSINESS-LOGIC.md specs/03-ADMIN-UI.md specs/09-IMPLEMENTATION-ROADMAP.md app tests database -g '*.php'

query: synonym|stop word|stop_words|synonyms|SearchService|products_fts|search settings

aggregated_output:
database/factories/SearchSettingsFactory.php:22:            'synonyms_json' => [
database/factories/SearchSettingsFactory.php:26:            'stop_words_json' => ['the', 'a', 'an'],
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 |
specs/05-BUSINESS-LOGIC.md:1564:**Service:** `App\Services\SearchService`
specs/05-BUSINESS-LOGIC.md:1655:| SearchService | `App\Services` | FTS5 search queries |
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:868:**File to create:** `App\Services\SearchService`
specs/09-IMPLEMENTATION-ROADMAP.md:877:**File to create:** `App\Observers\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.
specs/09-IMPLEMENTATION-ROADMAP.md:1980:| `SearchService` | FTS5 index management, query execution, autocomplete |
specs/03-ADMIN-UI.md:2221:| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |
specs/03-ADMIN-UI.md:2222:| `stopWords` | `string` | Comma-separated stop words |
specs/03-ADMIN-UI.md:2229:| `save()` | Saves synonym and stop word settings |
specs/03-ADMIN-UI.md:2230:| `addSynonymGroup()` | Adds a new empty synonym group |
specs/03-ADMIN-UI.md:2231:| `removeSynonymGroup(int $index)` | Removes a synonym group |
specs/03-ADMIN-UI.md:2247:| [+ Add synonym group]                                             |
specs/03-ADMIN-UI.md:2271:- For each synonym group: a row with text input (placeholder: "t-shirt, tee, tshirt") and trash button
specs/03-ADMIN-UI.md:2272:- "Add synonym group" ghost button with plus icon
tests/Feature/Search/SearchServiceTest.php:6:use App\Services\SearchService;
tests/Feature/Search/SearchServiceTest.php:47:    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);
tests/Feature/Search/SearchServiceTest.php:69:    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);
tests/Feature/Search/SearchServiceTest.php:73:    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
tests/Feature/Search/SearchServiceTest.php:74:        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);
tests/Feature/Search/SearchServiceTest.php:78:    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
tests/Feature/Search/SearchServiceTest.php:96:    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);
app/Services/SearchService.php:19:class SearchService
app/Services/SearchService.php:56:            ->orderByRaw('products_fts.rank')
app/Services/SearchService.php:168:        DB::table('products_fts')
app/Services/SearchService.php:172:        DB::table('products_fts')->insert([
app/Services/SearchService.php:185:        DB::table('products_fts')
app/Services/SearchService.php:192:        DB::table('products_fts')
app/Services/SearchService.php:239:            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
app/Services/SearchService.php:240:            ->where('products_fts.store_id', $store->getKey())
app/Services/SearchService.php:241:            ->whereRaw('products_fts MATCH ?', [$match]);
app/Services/SearchService.php:351:                ? $builder->orderByRaw('products_fts.rank')
tests/Feature/Admin/SearchSettingsTest.php:8:use App\Services\SearchService;
tests/Feature/Admin/SearchSettingsTest.php:31:test('admin search settings route renders for store admins', function (): void {
tests/Feature/Admin/SearchSettingsTest.php:39:test('admin search settings saves synonyms and stop words', function (): void {
tests/Feature/Admin/SearchSettingsTest.php:45:        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
tests/Feature/Admin/SearchSettingsTest.php:52:    expect($settings->synonyms_json)->toBe([
tests/Feature/Admin/SearchSettingsTest.php:55:    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
tests/Feature/Admin/SearchSettingsTest.php:58:test('admin search settings can rebuild the search index', function (): void {
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:79:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
app/Observers/ProductObserver.php:6:use App\Services\SearchService;
app/Observers/ProductObserver.php:12:        app(SearchService::class)->syncProduct($product);
app/Observers/ProductObserver.php:17:        app(SearchService::class)->syncProduct($product);
app/Observers/ProductObserver.php:22:        app(SearchService::class)->removeProduct($product->getKey());
app/Observers/ProductObserver.php:27:        app(SearchService::class)->removeProduct($product->getKey());
database/seeders/SearchSettingsSeeder.php:22:                        'synonyms_json' => [
database/seeders/SearchSettingsSeeder.php:26:                        'stop_words_json' => ['the', 'a', 'an', 'is', 'are'],
app/Providers/AppServiceProvider.php:25:use App\Services\SearchService;
app/Providers/AppServiceProvider.php:58:        $this->app->singleton(SearchService::class);
app/Models/SearchSettings.php:26:        'synonyms_json',
app/Models/SearchSettings.php:27:        'stop_words_json',
app/Models/SearchSettings.php:34:        'synonyms_json' => '[]',
app/Models/SearchSettings.php:35:        'stop_words_json' => '[]',
app/Models/SearchSettings.php:52:            'synonyms_json' => 'array',
app/Models/SearchSettings.php:53:            'stop_words_json' => 'array',
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');
app/Livewire/Storefront/Search/Index.php:7:use App\Services\SearchService;
app/Livewire/Storefront/Search/Index.php:60:        return app(SearchService::class)->search(
app/Livewire/Admin/Search/Settings.php:7:use App\Services\SearchService;
app/Livewire/Admin/Search/Settings.php:22:    public array $synonymGroups = [];
app/Livewire/Admin/Search/Settings.php:41:        $this->synonymGroups = collect($settings->synonyms_json ?? [])
app/Livewire/Admin/Search/Settings.php:45:        $this->stopWords = implode(', ', $settings->stop_words_json ?? []);
app/Livewire/Admin/Search/Settings.php:51:        $this->synonymGroups[] = '';
app/Livewire/Admin/Search/Settings.php:56:        unset($this->synonymGroups[$index]);
app/Livewire/Admin/Search/Settings.php:57:        $this->synonymGroups = array_values($this->synonymGroups);
app/Livewire/Admin/Search/Settings.php:65:            'synonymGroups' => ['array', 'max:50'],
app/Livewire/Admin/Search/Settings.php:66:            'synonymGroups.*' => ['nullable', 'string', 'max:500'],
app/Livewire/Admin/Search/Settings.php:69:            'synonymGroups.*' => 'synonym group',
app/Livewire/Admin/Search/Settings.php:70:            'stopWords' => 'stop words',
app/Livewire/Admin/Search/Settings.php:75:            'synonyms_json' => $this->parseSynonyms(),
app/Livewire/Admin/Search/Settings.php:76:            'stop_words_json' => $this->parseWordList($this->stopWords),
app/Livewire/Admin/Search/Settings.php:85:    public function triggerReindex(SearchService $search): void
app/Livewire/Admin/Search/Settings.php:124:        return collect($this->synonymGroups)
app/Http/Controllers/Api/Storefront/V1/SearchController.php:10:use App\Services\SearchService;
app/Http/Controllers/Api/Storefront/V1/SearchController.php:15:    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
app/Http/Controllers/Api/Storefront/V1/SearchController.php:43:    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
database/migrations/2026_05_04_030847_create_search_settings_table.php:16:            $table->text('synonyms_json')->default('[]');
database/migrations/2026_05_04_030847_create_search_settings_table.php:17:            $table->text('stop_words_json')->default('[]');


output:
Chunk ID: 87740d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2245
Output:
database/factories/SearchSettingsFactory.php:22:            'synonyms_json' => [
database/factories/SearchSettingsFactory.php:26:            'stop_words_json' => ['the', 'a', 'an'],
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 |
specs/05-BUSINESS-LOGIC.md:1564:**Service:** `App\Services\SearchService`
specs/05-BUSINESS-LOGIC.md:1655:| SearchService | `App\Services` | FTS5 search queries |
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:868:**File to create:** `App\Services\SearchService`
specs/09-IMPLEMENTATION-ROADMAP.md:877:**File to create:** `App\Observers\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.
specs/09-IMPLEMENTATION-ROADMAP.md:1980:| `SearchService` | FTS5 index management, query execution, autocomplete |
specs/03-ADMIN-UI.md:2221:| `synonymGroups` | `array` | Array of synonym groups, each a comma-separated string |
specs/03-ADMIN-UI.md:2222:| `stopWords` | `string` | Comma-separated stop words |
specs/03-ADMIN-UI.md:2229:| `save()` | Saves synonym and stop word settings |
specs/03-ADMIN-UI.md:2230:| `addSynonymGroup()` | Adds a new empty synonym group |
specs/03-ADMIN-UI.md:2231:| `removeSynonymGroup(int $index)` | Removes a synonym group |
specs/03-ADMIN-UI.md:2247:| [+ Add synonym group]                                             |
specs/03-ADMIN-UI.md:2271:- For each synonym group: a row with text input (placeholder: "t-shirt, tee, tshirt") and trash button
specs/03-ADMIN-UI.md:2272:- "Add synonym group" ghost button with plus icon
tests/Feature/Search/SearchServiceTest.php:6:use App\Services\SearchService;
tests/Feature/Search/SearchServiceTest.php:47:    $results = app(SearchService::class)->search($store, 'viscose search', [], 12);
tests/Feature/Search/SearchServiceTest.php:69:    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(1);
tests/Feature/Search/SearchServiceTest.php:73:    expect(app(SearchService::class)->search($store, 'copper sync', [], 12)->total())->toBe(0)
tests/Feature/Search/SearchServiceTest.php:74:        ->and(app(SearchService::class)->search($store, 'graphite sync', [], 12)->total())->toBe(1);
tests/Feature/Search/SearchServiceTest.php:78:    expect(DB::table('products_fts')->where('product_id', $product->getKey())->exists())->toBeFalse();
tests/Feature/Search/SearchServiceTest.php:96:    $results = app(SearchService::class)->search($store, 'pagination merino', [], 12);
app/Services/SearchService.php:19:class SearchService
app/Services/SearchService.php:56:            ->orderByRaw('products_fts.rank')
app/Services/SearchService.php:168:        DB::table('products_fts')
app/Services/SearchService.php:172:        DB::table('products_fts')->insert([
app/Services/SearchService.php:185:        DB::table('products_fts')
app/Services/SearchService.php:192:        DB::table('products_fts')
app/Services/SearchService.php:239:            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
app/Services/SearchService.php:240:            ->where('products_fts.store_id', $store->getKey())
app/Services/SearchService.php:241:            ->whereRaw('products_fts MATCH ?', [$match]);
app/Services/SearchService.php:351:                ? $builder->orderByRaw('products_fts.rank')
tests/Feature/Admin/SearchSettingsTest.php:8:use App\Services\SearchService;
tests/Feature/Admin/SearchSettingsTest.php:31:test('admin search settings route renders for store admins', function (): void {
tests/Feature/Admin/SearchSettingsTest.php:39:test('admin search settings saves synonyms and stop words', function (): void {
tests/Feature/Admin/SearchSettingsTest.php:45:        ->set('synonymGroups', ['hoodie, sweatshirt, pullover', 'tee, t-shirt'])
tests/Feature/Admin/SearchSettingsTest.php:52:    expect($settings->synonyms_json)->toBe([
tests/Feature/Admin/SearchSettingsTest.php:55:    ])->and($settings->stop_words_json)->toBe(['the', 'and', 'for']);
tests/Feature/Admin/SearchSettingsTest.php:58:test('admin search settings can rebuild the search index', function (): void {
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:79:    expect(app(SearchService::class)->search($store, 'reindex jacket', [], 12)->total())->toBe(1);
app/Observers/ProductObserver.php:6:use App\Services\SearchService;
app/Observers/ProductObserver.php:12:        app(SearchService::class)->syncProduct($product);
app/Observers/ProductObserver.php:17:        app(SearchService::class)->syncProduct($product);
app/Observers/ProductObserver.php:22:        app(SearchService::class)->removeProduct($product->getKey());
app/Observers/ProductObserver.php:27:        app(SearchService::class)->removeProduct($product->getKey());
database/seeders/SearchSettingsSeeder.php:22:                        'synonyms_json' => [
database/seeders/SearchSettingsSeeder.php:26:                        'stop_words_json' => ['the', 'a', 'an', 'is', 'are'],
app/Providers/AppServiceProvider.php:25:use App\Services\SearchService;
app/Providers/AppServiceProvider.php:58:        $this->app->singleton(SearchService::class);
app/Models/SearchSettings.php:26:        'synonyms_json',
app/Models/SearchSettings.php:27:        'stop_words_json',
app/Models/SearchSettings.php:34:        'synonyms_json' => '[]',
app/Models/SearchSettings.php:35:        'stop_words_json' => '[]',
app/Models/SearchSettings.php:52:            'synonyms_json' => 'array',
app/Models/SearchSettings.php:53:            'stop_words_json' => 'array',
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');
app/Livewire/Storefront/Search/Index.php:7:use App\Services\SearchService;
app/Livewire/Storefront/Search/Index.php:60:        return app(SearchService::class)->search(
app/Livewire/Admin/Search/Settings.php:7:use App\Services\SearchService;
app/Livewire/Admin/Search/Settings.php:22:    public array $synonymGroups = [];
app/Livewire/Admin/Search/Settings.php:41:        $this->synonymGroups = collect($settings->synonyms_json ?? [])
app/Livewire/Admin/Search/Settings.php:45:        $this->stopWords = implode(', ', $settings->stop_words_json ?? []);
app/Livewire/Admin/Search/Settings.php:51:        $this->synonymGroups[] = '';
app/Livewire/Admin/Search/Settings.php:56:        unset($this->synonymGroups[$index]);
app/Livewire/Admin/Search/Settings.php:57:        $this->synonymGroups = array_values($this->synonymGroups);
app/Livewire/Admin/Search/Settings.php:65:            'synonymGroups' => ['array', 'max:50'],
app/Livewire/Admin/Search/Settings.php:66:            'synonymGroups.*' => ['nullable', 'string', 'max:500'],
app/Livewire/Admin/Search/Settings.php:69:            'synonymGroups.*' => 'synonym group',
app/Livewire/Admin/Search/Settings.php:70:            'stopWords' => 'stop words',
app/Livewire/Admin/Search/Settings.php:75:            'synonyms_json' => $this->parseSynonyms(),
app/Livewire/Admin/Search/Settings.php:76:            'stop_words_json' => $this->parseWordList($this->stopWords),
app/Livewire/Admin/Search/Settings.php:85:    public function triggerReindex(SearchService $search): void
app/Livewire/Admin/Search/Settings.php:124:        return collect($this->synonymGroups)
app/Http/Controllers/Api/Storefront/V1/SearchController.php:10:use App\Services\SearchService;
app/Http/Controllers/Api/Storefront/V1/SearchController.php:15:    public function index(SearchProductsRequest $request, SearchService $search): JsonResponse
app/Http/Controllers/Api/Storefront/V1/SearchController.php:43:    public function suggest(SuggestProductsRequest $request, SearchService $search): JsonResponse
database/migrations/2026_05_04_030847_create_search_settings_table.php:16:            $table->text('synonyms_json')->default('[]');
database/migrations/2026_05_04_030847_create_search_settings_table.php:17:            $table->text('stop_words_json')->default('[]');


output:
Chunk ID: ba6ee1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3689
Output:
<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Enums\ProductStatus;
use App\Models\Collection as ProductCollection;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\SearchQuery;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class SearchService
{
    /**
     * @param  array<string, mixed>  $filters
     */
    public function search(Store $store, string $query, array $filters = [], int $perPage = 24, string $sort = 'relevance'): LengthAwarePaginator
    {
        $query = trim($query);
        $perPage = max(1, min(50, $perPage));

        if ($query === '') {
            return $this->browse($store, $filters, $perPage, $sort);
        }

        $paginator = $this->baseSearchQuery($store, $query, $filters)
            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, true))
            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());

        $paginator->setCollection($this->hydrateProducts(
            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
        ));

        $this->log($store, $query, $filters, $paginator->total());

        return $paginator;
    }

    public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection
    {
        $prefix = trim($prefix);
        $limit = max(1, min(10, $limit));

        if ($prefix === '') {
            return collect();
        }

        $ids = $this->baseSearchQuery($store, $prefix, [])
            ->orderByRaw('products_fts.rank')
            ->limit($limit)
            ->pluck('products.id')
            ->map(fn (mixed $id): int => (int) $id)
            ->all();

        return $this->hydrateProducts($ids);
    }

    public function suggestions(Store $store, string $prefix, int $limit = 5): Collection
    {
        $productLimit = max(1, min(10, $limit));

        $products = $this->autocomplete($store, $prefix, $productLimit)
            ->map(function (Product $product): array {
                $variant = $product->variants->first();

                return [
                    'type' => 'product',
                    'title' => $product->title,
                    'handle' => $product->handle,
                    'image_url' => $product->media->first()?->storage_key,
                    'price_amount' => $variant?->price_amount,
                    'currency' => $variant?->currency ?? $product->store?->default_currency,
                ];
            });

        $remaining = max(0, $productLimit - $products->count());

        $collections = $remaining > 0
            ? ProductCollection::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('status', 'active')
                ->where('title', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], trim($prefix)).'%')
                ->oldest('title')
                ->limit($remaining)
                ->get()
                ->map(fn (ProductCollection $collection): array => [
                    'type' => 'collection',
                    'title' => $collection->title,
                    'handle' => $collection->handle,
                    'image_url' => null,
                ])
            : collect();

        return $products->concat($collections)->values();
    }

    /**
     * @param  array<string, mixed>  $filters
     * @return array{vendors: list<array{value: string, count: int}>, tags: list<array{value: string, count: int}>, price_range: array{min: int|null, max: int|null}}
     */
    public function facets(Store $store, string $query, array $filters = []): array
    {
        $ids = trim($query) === ''
            ? $this->activeProductQuery($store, $filters)->limit(1000)->pluck('products.id')->all()
            : $this->baseSearchQuery($store, $query, $filters)->limit(1000)->pluck('products.id')->all();

        $ids = collect($ids)->map(fn (mixed $id): int => (int) $id)->all();

        if ($ids === []) {
            return [
                'vendors' => [],
                'tags' => [],
                'price_range' => ['min' => null, 'max' => null],
            ];
        }

        $vendors = Product::withoutGlobalScopes()
            ->whereIn('id', $ids)
            ->whereNotNull('vendor')
            ->selectRaw('vendor as value, COUNT(*) as count')
            ->groupBy('vendor')
            ->orderBy('vendor')
            ->get()
            ->map(fn (Product $product): array => [
                'value' => (string) $product->value,
                'count' => (int) $product->count,
            ])
            ->all();

        $tags = Product::withoutGlobalScopes()
            ->whereIn('id', $ids)
            ->get(['tags'])
            ->flatMap(fn (Product $product): array => $product->tags ?? [])
            ->filter()
            ->countBy()
            ->sortKeys()
            ->map(fn (int $count, string $tag): array => [
                'value' => $tag,
                'count' => $count,
            ])
            ->values()
            ->all();

        $priceRange = ProductVariant::withoutGlobalScopes()
            ->whereIn('product_id', $ids)
            ->selectRaw('MIN(price_amount) as min_price, MAX(price_amount) as max_price')
            ->first();

        return [
            'vendors' => $vendors,
            'tags' => $tags,
            'price_range' => [
                'min' => $priceRange?->min_price === null ? null : (int) $priceRange->min_price,
                'max' => $priceRange?->max_price === null ? null : (int) $priceRange->max_price,
            ],
        ];
    }

    public function syncProduct(Product $product): void
    {
        DB::table('products_fts')
            ->where('product_id', $product->getKey())
            ->delete();

        DB::table('products_fts')->insert([
            'store_id' => $product->store_id,
            'product_id' => $product->getKey(),
            'title' => $product->title,
            'description' => trim(strip_tags((string) $product->description_html)),
            'vendor' => (string) $product->vendor,
            'product_type' => (string) $product->product_type,
            'tags' => collect(Arr::wrap($product->tags))->filter()->implode(' '),
        ]);
    }

    public function removeProduct(int $productId): void
    {
        DB::table('products_fts')
            ->where('product_id', $productId)
            ->delete();
    }

    public function reindex(Store $store): int
    {
        DB::table('products_fts')
            ->where('store_id', $store->getKey())
            ->delete();

        $count = 0;

        Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->chunkById(100, function (Collection $products) use (&$count): void {
                $products->each(function (Product $product) use (&$count): void {
                    $this->syncProduct($product);
                    $count++;
                });
            });

        return $count;
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function browse(Store $store, array $filters, int $perPage, string $sort): LengthAwarePaginator
    {
        $paginator = $this->activeProductQuery($store, $filters)
            ->tap(fn (QueryBuilder $builder) => $this->applySort($builder, $sort, false))
            ->paginate($perPage, ['products.id'], 'page', Paginator::resolveCurrentPage());

        $paginator->setCollection($this->hydrateProducts(
            collect($paginator->items())->pluck('id')->map(fn (mixed $id): int => (int) $id)->all()
        ));

        return $paginator;
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function baseSearchQuery(Store $store, string $query, array $filters): QueryBuilder
    {
        $match = $this->toFtsQuery($query);

        if ($match === '') {
            return $this->activeProductQuery($store, $filters);
        }

        return $this->activeProductQuery($store, $filters)
            ->join('products_fts', 'products_fts.product_id', '=', 'products.id')
            ->where('products_fts.store_id', $store->getKey())
            ->whereRaw('products_fts MATCH ?', [$match]);
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function activeProductQuery(Store $store, array $filters): QueryBuilder
    {
        $builder = DB::table('products')
            ->select('products.id')
            ->where('products.store_id', $store->getKey())
            ->where('products.status', ProductStatus::Active->value)
            ->whereNotNull('products.published_at');

        $this->applyFilters($builder, $filters);

        return $builder;
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function applyFilters(QueryBuilder $builder, array $filters): void
    {
        if (isset($filters['collection_id'])) {
            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
                $query
                    ->selectRaw('1')
                    ->from('collection_products')
                    ->whereColumn('collection_products.product_id', 'products.id')
                    ->where('collection_products.collection_id', (int) $filters['collection_id']);
            });
        }

        $vendors = collect(Arr::wrap($filters['vendor'] ?? []))
            ->filter(fn (mixed $vendor): bool => filled($vendor))
            ->map(fn (mixed $vendor): string => (string) $vendor)
            ->values()
            ->all();

        if ($vendors !== []) {
            $builder->whereIn('products.vendor', $vendors);
        }

        $productTypes = collect(Arr::wrap($filters['product_type'] ?? []))
            ->filter(fn (mixed $productType): bool => filled($productType))
            ->map(fn (mixed $productType): string => (string) $productType)
            ->values()
            ->all();

        if ($productTypes !== []) {
            $builder->whereIn('products.product_type', $productTypes);
        }

        foreach (Arr::wrap($filters['tags'] ?? []) as $tag) {
            if (filled($tag)) {
                $builder->where('products.tags', 'like', '%"'.str_replace(['%', '_'], ['\%', '\_'], (string) $tag).'"%');
            }
        }

        if (isset($filters['price_min']) || isset($filters['price_max'])) {
            $builder->whereExists(function (QueryBuilder $query) use ($filters): void {
                $query
                    ->selectRaw('1')
                    ->from('product_variants')
                    ->whereColumn('product_variants.product_id', 'products.id');

                if (isset($filters['price_min'])) {
                    $query->where('product_variants.price_amount', '>=', (int) $filters['price_min']);
                }

                if (isset($filters['price_max'])) {
                    $query->where('product_variants.price_amount', '<=', (int) $filters['price_max']);
                }
            });
        }

        if (filter_var($filters['in_stock'] ?? false, FILTER_VALIDATE_BOOL)) {
            $builder->whereExists(function (QueryBuilder $query): void {
                $query
                    ->selectRaw('1')
                    ->from('product_variants')
                    ->join('inventory_items', 'inventory_items.variant_id', '=', 'product_variants.id')
                    ->whereColumn('product_variants.product_id', 'products.id')
                    ->where(function (QueryBuilder $query): void {
                        $query
                            ->whereRaw('inventory_items.quantity_on_hand > inventory_items.quantity_reserved')
                            ->orWhere('inventory_items.policy', InventoryPolicy::Continue->value);
                    });
            });
        }
    }

    private function applySort(QueryBuilder $builder, string $sort, bool $hasRank): void
    {
        match ($sort) {
            'price_asc' => $builder
                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC')
                ->orderBy('products.id'),
            'price_desc' => $builder
                ->orderByRaw('(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC')
                ->orderBy('products.id'),
            'newest' => $builder
                ->orderByDesc('products.published_at')
                ->orderByDesc('products.id'),
            'best_selling' => $builder
                ->orderByRaw('(SELECT COALESCE(SUM(order_lines.quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC')
                ->orderByDesc('products.published_at')
                ->orderByDesc('products.id'),
            default => $hasRank
                ? $builder->orderByRaw('products_fts.rank')
                : $builder->orderByDesc('products.published_at')->orderByDesc('products.id'),
        };
    }

    /**
     * @param  list<int>  $ids
     * @return Collection<int, Product>
     */
    private function hydrateProducts(array $ids): Collection
    {
        if ($ids === []) {
            return collect();
        }

        $products = Product::withoutGlobalScopes()
            ->with(['store', 'variants.inventoryItem', 'media'])
            ->withCount('variants')
            ->whereIn('id', $ids)
            ->get()
            ->keyBy('id');

        return collect($ids)
            ->map(fn (int $id): ?Product => $products->get($id))
            ->filter()
            ->values();
    }

    private function toFtsQuery(string $query): string
    {
        $tokens = preg_split('/[^\pL\pN]+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $tokens = array_slice($tokens, 0, 8);
        $lastIndex = count($tokens) - 1;

        return collect($tokens)
            ->map(fn (string $token, int $index): string => $index === $lastIndex ? "{$token}*" : $token)
            ->implode(' ');
    }

    /**
     * @param  array<string, mixed>  $filters
     */
    private function log(Store $store, string $query, array $filters, int $resultsCount): void
    {
        SearchQuery::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'query' => mb_substr($query, 0, 200),
            'filters_json' => $filters,
            'results_count' => $resultsCount,
            'created_at' => now(),
        ]);
    }
}