Output:
Wall time: 3.3086 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Database Testing Model Factories\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n## Model Factories\n\nWhen testing, you may need to insert a few records into your database before executing your test. Instead of manually specifying the value of each column when you create this test data, Laravel allows you to define a set of default attributes for each of your [Eloquent models](/docs/{{version}}/eloquent) using [model factories](/docs/{{version}}/eloquent-factories).\n\nTo learn more about creating and utilizing model factories to create models, please consult the complete [model factory documentation](/docs/{{version}}/eloquent-factories). Once you have defined a model factory, you may utilize the factory within your test to create models:\n\n```php tab=Pest\nuse App\\Models\\User;\n\ntest('models can be instantiated', function () {\n    $user = User::factory()->create();\n\n    // ...\n});\n```\n\n```php tab=PHPUnit\nuse App\\Models\\User;\n\npublic function test_models_can_be_instantiated(): void\n{\n    $user = User::factory()->create();\n\n    // ...\n}\n```\n\n---\n\n## Urls URLs for Named Routes Eloquent Models\nUrls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md\n\n\n#### Eloquent Models\n\nYou will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:\n\n```php\necho route('post.show', ['post' => $post]);\n```\n\n---\n\n## Eloquent Factories Factory Relationships\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n## Factory Relationships\n\n---\n\n## Database Testing Introduction\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n## Introduction\n\nLaravel provides a variety of helpful tools and assertions to make it easier to test your database driven applications. In addition, Laravel model factories and seeders make it painless to create test database records using your application's Eloquent models and relationships. We'll discuss all of these powerful features in the following documentation.\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Models and UUIDv7\n\n**Likelihood Of Impact: Medium**\n\nThe `HasUuids` trait now returns UUIDs that are compatible with version 7 of the UUID spec (ordered UUIDs). If you would like to continue using ordered UUIDv4 strings for your model's IDs, you should now use the `HasVersion4Uuids` trait:\n\n```php\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasUuids; // [tl! remove]\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasVersion4Uuids as HasUuids; // [tl! add]\n```\n\nThe `HasVersion7Uuids` trait has been removed. If you were previously using this trait, you should use the `HasUuids` trait instead, which now provides the same behavior.\n\n---\n\n## Eloquent Factories Factory Relationships Defining Relationships Within Factories\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n### Defining Relationships Within Factories\n\nTo define a relationship within your model factory, you will typically assign a new factory instance to the foreign key of the relationship. This is normally done for the \"inverse\" relationships such as `belongsTo` and `morphTo` relationships. For example, if you would like to create a new user when creating a post, you may do the following:\n\n```php\nuse App\\Models\\User;\n\n/**\n * Define the model's default state.\n *\n * @return array<string, mixed>\n */\npublic function definition(): array\n{\n    return [\n        'user_id' => User::factory(),\n        'title' => fake()->title(),\n        'content' => fake()->paragraph(),\n    ];\n}\n```\n\nIf the relationship's columns depend on the factory that defines it you may assign a closure to an attribute. The closure will receive the factory's evaluated attribute array:\n\n```php\n/**\n * Define the model's default state.\n *\n * @return array<string, mixed>\n */\npublic function definition(): array\n{\n    return [\n        'user_id' => User::factory(),\n        'user_type' => function (array $attributes) {\n            return User::find($attributes['user_id'])->type;\n        },\n        'title' => fake()->title(),\n        'content' => fake()->paragraph(),\n    ];\n}\n```\n\n---\n\n## Scout Searching Where Clauses Customizing the Eloquent Results Query\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n#### Customizing the Eloquent Results Query\n\nAfter Scout retrieves a list of matching Eloquent models from your application's search engine, Eloquent is used to retrieve all of the matching models by their primary keys. You may customize this query by invoking the `query` method. The `query` method accepts a closure that will receive the Eloquent query builder instance as an argument:\n\n```php\nuse App\\Models\\Order;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\n$orders = Order::search('Star Trek')\n    ->query(fn (Builder $query) => $query->with('invoices'))\n    ->get();\n```\n\nWhen using a third-party engine, this callback is invoked after the relevant models have already been retrieved from the search engine, so it should not be used for \"filtering\" results — use [Scout where clauses](#where-clauses) instead. However, when using the database engine, the `query` method's constraints are applied directly to the database query, so you may use it for filtering as well.\n\n---\n\n## Eloquent Factories Factory Relationships Belongs To Relationships\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n### Belongs To Relationships\n\nNow that we have explored how to build \"has many\" relationships using factories, let's explore the inverse of the relationship. The `for` method may be used to define the parent model that factory created models belong to. For example, we can create three `App\\Models\\Post` model instances that belong to a single user:\n\n```php\nuse App\\Models\\Post;\nuse App\\Models\\User;\n\n$posts = Post::factory()\n    ->count(3)\n    ->for(User::factory()->state([\n        'name' => 'Jessica Archer',\n    ]))\n    ->create();\n```\n\nIf you already have a parent model instance that should be associated with the models you are creating, you may pass the model instance to the `for` method:\n\n```php\n$user = User::factory()->create();\n\n$posts = Post::factory()\n    ->count(3)\n    ->for($user)\n    ->create();\n```\n\n---\n\n## Responses Redirects Redirecting to Named Routes Populating Parameters via Eloquent Models\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n#### Populating Parameters via Eloquent Models\n\nIf you are redirecting to a route with an \"ID\" parameter that is being populated from an Eloquent model, you may pass the model itself. The ID will be extracted automatically:\n\n```php\n// For a route with the following URI: /profile/{id}\n\nreturn redirect()->route('profile', [$user]);\n```\n\nIf you would like to customize the value that is placed in the route parameter, you can specify the column in the route parameter definition (`/profile/{id:slug}`) or you can override the `getRouteKey` method on your Eloquent model:\n\n```php\n/**\n * Get the value of the model's route key.\n */\npublic function getRouteKey(): mixed\n{\n    return $this->slug;\n}\n```\n\n---\n\n## Eloquent Factories\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n# Eloquent: Factories\n\n- [Introduction](#introduction)\n- [Defining Model Factories](#defining-model-factories)\n    - [Generating Factories](#generating-factories)\n    - [Factory States](#factory-states)\n    - [Factory Callbacks](#factory-callbacks)\n- [Creating Models Using Factories](#creating-models-using-factories)\n    - [Instantiating Models](#instantiating-models)\n    - [Persisting Models](#persisting-models)\n    - [Sequences](#sequences)\n- [Factory Relationships](#factory-relationships)\n    - [Has Many Relationships](#has-many-relationships)\n    - [Belongs To Relationships](#belongs-to-relationships)\n    - [Many to Many Relationships](#many-to-many-relationships)\n    - [Polymorphic Relationships](#polymorphic-relationships)\n    - [Defining Relationships Within Factories](#defining-relationships-within-factories)\n    - [Recycling an Existing Model for Relationships](#recycling-an-existing-model-for-relationships)\n\n---\n\n## Eloquent Factories Factory Relationships Polymorphic Relationships\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n### Polymorphic Relationships\n\n[Polymorphic relationships](/docs/{{version}}/eloquent-relationships#polymorphic-relationships) may also be created using factories. Polymorphic \"morph many\" relationships are created in the same way as typical \"has many\" relationships. For example, if an `App\\Models\\Post` model has a `morphMany` relationship with an `App\\Models\\Comment` model:\n\n```php\nuse App\\Models\\Post;\n\n$post = Post::factory()->hasComments(3)->create();\n```\n\n---\n\n## Database Testing Available Assertions assertModelExists\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n#### assertModelExists\n\nAssert that a given model or collection of models exist in the database:\n\n```php\nuse App\\Models\\User;\n\n$user = User::factory()->create();\n\n$this->assertModelExists($user);\n```\n\n---\n\n## Eloquent Factories Creating Models Using Factories\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n## Creating Models Using Factories\n\n---\n\n## Responses Creating Responses Eloquent Models and Collections\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n#### Eloquent Models and Collections\n\nYou may also return [Eloquent ORM](/docs/{{version}}/eloquent) models and collections directly from your routes and controllers. When you do, Laravel will automatically convert the models and collections to JSON responses while respecting the model's [hidden attributes](/docs/{{version}}/eloquent-serialization#hiding-attributes-from-json):\n\n```php\nuse App\\Models\\User;\n\nRoute::get('/user/{user}', function (User $user) {\n    return $user;\n});\n```\n\n---\n\n## Database Testing Available Assertions assertModelMissing\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n#### assertModelMissing\n\nAssert that a given model or collection of models do not exist in the database:\n\n```php\nuse App\\Models\\User;\n\n$user = User::factory()->create();\n\n$user->delete();\n\n$this->assertModelMissing($user);\n```\n\n---\n\n## Pagination Basic Usage Paginating Eloquent Results\nPagination | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pagination.md\n\n\n### Paginating Eloquent Results\n\nYou may also paginate [Eloquent](/docs/{{version}}/eloquent) queries. In this example, we will paginate the `App\\Models\\User` model and indicate that we plan to display 15 records per page. As you can see, the syntax is nearly identical to paginating query builder results:\n\n```php\nuse App\\Models\\User;\n\n$users = User::paginate(15);\n```\n\nOf course, you may call the `paginate` method after setting other constraints on the query, such as `where` clauses:\n\n```php\n$users = User::where('votes', '>', 100)->paginate(15);\n```\n\nYou may also use the `simplePaginate` method when paginating Eloquent models:\n\n```php\n$users = User::where('votes', '>', 100)->simplePaginate(15);\n```\n\nSimilarly, you may use the `cursorPaginate` method to cursor paginate Eloquent models:\n\n```php\n$users = User::where('votes', '>', 100)->cursorPaginate(15);\n```\n\n---\n\n## Database Testing\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n# Database Testing\n\n- [Introduction](#introduction)\n    - [Resetting the Database After Each Test](#resetting-the-database-after-each-test)\n- [Model Factories](#model-factories)\n- [Running Seeders](#running-seeders)\n- [Available Assertions](#available-assertions)\n\n---\n\n## Eloquent Events Muting Events Saving a Single Model Without Events\nEloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md\n\n\n#### Saving a Single Model Without Events\n\nSometimes you may wish to \"save\" a given model without dispatching any events. You may accomplish this using the `saveQuietly` method:\n\n```php\n$user = User::findOrFail(1);\n\n$user->name = 'Victoria Faith';\n\n$user->saveQuietly();\n```\n\nYou may also \"update\", \"delete\", \"soft delete\", \"restore\", and \"replicate\" a given model without dispatching any events:\n\n```php\n$user->deleteQuietly();\n$user->forceDeleteQuietly();\n$user->restoreQuietly();\n```\n\n---\n\n## Pagination Basic Usage Paginating Eloquent Results Multiple Paginator Instances per Page\nPagination | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pagination.md\n\n\n#### Multiple Paginator Instances per Page\n\nSometimes you may need to render two separate paginators on a single screen that is rendered by your application. However, if both paginator instances use the `page` query string parameter to store the current page, the two paginators will conflict. To resolve this conflict, you may pass the name of the query string parameter you wish to use to store the paginator's current page via the third argument provided to the `paginate`, `simplePaginate`, and `cursorPaginate` methods:\n\n```php\nuse App\\Models\\User;\n\n$users = User::where('votes', '>', 100)->paginate(\n    $perPage = 15, $columns = ['*'], $pageName = 'users'\n);\n```\n\n---\n\n## Eloquent Events Muting Events\nEloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md\n\n\n### Muting Events\n\nYou may occasionally need to temporarily \"mute\" all events fired by a model. You may achieve this using the `withoutEvents` method. The `withoutEvents` method accepts a closure as its only argument. Any code executed within this closure will not dispatch model events, and any value returned by the closure will be returned by the `withoutEvents` method:\n\n```php\nuse App\\Models\\User;\n\n$user = User::withoutEvents(function () {\n    User::findOrFail(1)->delete();\n\n    return User::find(2);\n});\n```\n\n---\n\n## Eloquent Factories Factory Relationships Polymorphic Relationships Morph To Relationships\nEloquent Factories | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-factories.md\n\n\n#### Morph To Relationships\n\nMagic methods may not be used to create `morphTo` relationships. Instead, the `for` method must be used directly and the name of the relationship must be explicitly provided. For example, imagine that the `Comment` model has a `commentable` method that defines a `morphTo` relationship. In this situation, we may create three comments that belong to a single post by using the `for` method directly:\n\n```php\n$comments = Comment::factory()->count(3)->for(\n    Post::factory(), 'commentable'\n)->create();\n```\n\n---\n\n## Eloquent Events Observers Observers and Database Transactions\nEloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md\n\n\n#### Observers and Database Transactions\n\nWhen models are being created within a database transaction, you may want to instruct an observer to only execute its event handlers after the database transaction is committed. You may accomplish this by implementing the `ShouldHandleEventsAfterCommit` interface on your observer. If a database transaction is not in progress, the event handlers will execute immediately:\n\n```php\n<?php\n\nnamespace App\\Observers;\n\nuse App\\Models\\User;\nuse Illuminate\\Contracts\\Events\\ShouldHandleEventsAfterCommit;\n\nclass UserObserver implements ShouldHandleEventsAfterCommit\n{\n    /**\n     * Handle the User \"created\" event.\n     */\n    public function created(User $user): void\n    {\n        // ...\n    }\n}\n```\n\n---\n\n## Eloquent Events Using Closures\nEloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md\n\n\n### Using Closures\n\nInstead of using custom event classes, you may register closures that execute when various model events are dispatched. Typically, you should register these closures in the `booted` method of your model:\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass User extends Model\n{\n    /**\n     * The \"booted\" method of the model.\n     */\n    protected static function booted(): void\n    {\n        static::created(function (User $user) {\n            // ...\n        });\n    }\n}\n```\n\nIf needed, you may utilize [queueable anonymous event listeners](/docs/{{version}}/events#queuable-anonymous-event-listeners) when registering model events. This will instruct Laravel to execute the model event listener in the background using your application's [queue](/docs/{{version}}/queues):\n\n```php\nuse function Illuminate\\Events\\queueable;\n\nstatic::created(queueable(function (User $user) {\n    // ...\n}));\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## 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_[Truncated]_"}]


The versioned docs confirm the Laravel 12 pattern for `booted()` model event closures and `withoutEvents()` for factories that need to bypass those events. I’m checking the current catalog files now so the patch stays aligned with the existing service and test style.


sed -n '1,240p' app/Models/ProductVariant.php


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


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


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


sed -n '1,240p' database/factories/ProductFactory.php


sed -n '1,240p' database/factories/ProductFactory.php

<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Archived,
        ]);
    }

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            $variant = ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);

            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
                'quantity_on_hand' => 50,
            ]);
        });
    }
}


sed -n '1,240p' app/Models/ProductVariant.php

<?php

namespace App\Models;

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

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = $variant->product()->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => 'deny',
            ]);
        });
    }

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

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


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

<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Support\HandleGenerator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;

class ProductService
{
    public function __construct(
        private readonly HandleGenerator $handles,
    ) {}

    /**
     * @param  array<string, mixed>  $data
     */
    public function create(Store $store, array $data): Product
    {
        return DB::transaction(function () use ($store, $data): Product {
            $product = Product::query()->create([
                ...Arr::only($data, [
                    'title',
                    'status',
                    'description_html',
                    'vendor',
                    'product_type',
                    'tags',
                    'published_at',
                ]),
                'store_id' => $store->getKey(),
                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
            ]);

            foreach ($data['options'] ?? [] as $optionData) {
                $option = $product->options()->create(Arr::only($optionData, ['name', 'position']));

                foreach ($optionData['values'] ?? [] as $valueData) {
                    $option->values()->create(Arr::only($valueData, ['value', 'position']));
                }
            }

            $variants = $data['variants'] ?? [];

            if ($variants === []) {
                $variants = [[
                    'sku' => $data['sku'] ?? null,
                    'price_amount' => $data['price_amount'] ?? 0,
                    'currency' => $store->default_currency,
                    'is_default' => true,
                    'position' => 0,
                ]];
            }

            foreach ($variants as $variantData) {
                $this->createVariant($product, $store, $variantData);
            }

            return $product->refresh();
        });
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function update(Product $product, array $data): Product
    {
        return DB::transaction(function () use ($product, $data): Product {
            $storeId = $product->store_id;

            $product->fill(Arr::only($data, [
                'title',
                'status',
                'description_html',
                'vendor',
                'product_type',
                'tags',
                'published_at',
            ]));

            if (array_key_exists('handle', $data)) {
                $product->handle = $data['handle'];
            } elseif (array_key_exists('title', $data)) {
                $product->handle = $this->handles->generate($data['title'], 'products', $storeId, $product->getKey());
            }

            $product->save();

            return $product->refresh();
        });
    }

    public function transitionStatus(Product $product, ProductStatus $newStatus): void
    {
        $currentStatus = $this->productStatus($product);

        if ($currentStatus === $newStatus) {
            return;
        }

        if ($newStatus === ProductStatus::Active) {
            $this->assertCanActivate($product);
        }

        if ($newStatus === ProductStatus::Draft && $this->hasOrderLineReferences($product)) {
            throw InvalidProductTransitionException::because('Products with order history cannot be reverted to draft.');
        }

        if (! $this->isAllowedTransition($currentStatus, $newStatus)) {
            throw InvalidProductTransitionException::because("Cannot transition product from {$currentStatus->value} to {$newStatus->value}.");
        }

        $product->forceFill([
            'status' => $newStatus,
            'published_at' => $newStatus === ProductStatus::Active
                ? ($product->published_at ?? now())
                : $product->published_at,
        ])->save();

        ProductStatusChanged::dispatch($product->refresh(), $currentStatus, $newStatus);
    }

    public function delete(Product $product): void
    {
        if ($this->productStatus($product) !== ProductStatus::Draft || $this->hasOrderLineReferences($product)) {
            throw InvalidProductTransitionException::because('Only draft products with no order history can be deleted.');
        }

        $product->delete();
    }

    /**
     * @param  array<string, mixed>  $variantData
     */
    private function createVariant(Product $product, Store $store, array $variantData): ProductVariant
    {
        $this->assertSkuIsUnique($store, $variantData['sku'] ?? null);

        $variant = $product->variants()->create([
            ...Arr::only($variantData, [
                'sku',
                'barcode',
                'price_amount',
                'compare_at_amount',
                'currency',
                'weight_g',
                'requires_shipping',
                'is_default',
                'position',
                'status',
            ]),
            'currency' => $variantData['currency'] ?? $store->default_currency,
            'status' => $variantData['status'] ?? VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $store->getKey(),
                'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
                'quantity_reserved' => 0,
                'policy' => $variantData['inventory_policy'] ?? 'deny',
            ],
        );

        return $variant;
    }

    private function assertCanActivate(Product $product): void
    {
        if (trim((string) $product->title) === '') {
            throw InvalidProductTransitionException::because('A product title is required before activation.');
        }

        if (! $product->variants()->where('price_amount', '>', 0)->exists()) {
            throw InvalidProductTransitionException::because('At least one priced variant is required before activation.');
        }
    }

    private function isAllowedTransition(ProductStatus $from, ProductStatus $to): bool
    {
        return match ($from) {
            ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true),
            ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true),
            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
        };
    }

    private function hasOrderLineReferences(Product $product): bool
    {
        if (! Schema::hasTable('order_lines')) {
            return false;
        }

        $variantIds = $product->variants()->pluck('id');

        if ($variantIds->isEmpty()) {
            return false;
        }

        return DB::table('order_lines')
            ->whereIn('variant_id', $variantIds)
            ->exists();
    }

    private function assertSkuIsUnique(Store $store, ?string $sku, ?int $excludeVariantId = null): void
    {
        if ($sku === null || trim($sku) === '') {
            return;
        }

        $query = ProductVariant::query()
            ->where('sku', $sku)
            ->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey()));

        if ($excludeVariantId !== null) {
            $query->whereKeyNot($excludeVariantId);
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    private function productStatus(Product $product): ProductStatus
    {
        return $product->status instanceof ProductStatus
            ? $product->status
            : ProductStatus::from($product->status);
    }
}


Output:
Chunk ID: 6765c9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 705
Output:
<?php

namespace App\Models;

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

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = $variant->product()->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => 'deny',
            ]);
        });
    }

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

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


Output:
Chunk ID: 97c0d2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2010
Output:
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Support\HandleGenerator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;

class ProductService
{
    public function __construct(
        private readonly HandleGenerator $handles,
    ) {}

    /**
     * @param  array<string, mixed>  $data
     */
    public function create(Store $store, array $data): Product
    {
        return DB::transaction(function () use ($store, $data): Product {
            $product = Product::query()->create([
                ...Arr::only($data, [
                    'title',
                    'status',
                    'description_html',
                    'vendor',
                    'product_type',
                    'tags',
                    'published_at',
                ]),
                'store_id' => $store->getKey(),
                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
            ]);

            foreach ($data['options'] ?? [] as $optionData) {
                $option = $product->options()->create(Arr::only($optionData, ['name', 'position']));

                foreach ($optionData['values'] ?? [] as $valueData) {
                    $option->values()->create(Arr::only($valueData, ['value', 'position']));
                }
            }

            $variants = $data['variants'] ?? [];

            if ($variants === []) {
                $variants = [[
                    'sku' => $data['sku'] ?? null,
                    'price_amount' => $data['price_amount'] ?? 0,
                    'currency' => $store->default_currency,
                    'is_default' => true,
                    'position' => 0,
                ]];
            }

            foreach ($variants as $variantData) {
                $this->createVariant($product, $store, $variantData);
            }

            return $product->refresh();
        });
    }

    /**
     * @param  array<string, mixed>  $data
     */
    public function update(Product $product, array $data): Product
    {
        return DB::transaction(function () use ($product, $data): Product {
            $storeId = $product->store_id;

            $product->fill(Arr::only($data, [
                'title',
                'status',
                'description_html',
                'vendor',
                'product_type',
                'tags',
                'published_at',
            ]));

            if (array_key_exists('handle', $data)) {
                $product->handle = $data['handle'];
            } elseif (array_key_exists('title', $data)) {
                $product->handle = $this->handles->generate($data['title'], 'products', $storeId, $product->getKey());
            }

            $product->save();

            return $product->refresh();
        });
    }

    public function transitionStatus(Product $product, ProductStatus $newStatus): void
    {
        $currentStatus = $this->productStatus($product);

        if ($currentStatus === $newStatus) {
            return;
        }

        if ($newStatus === ProductStatus::Active) {
            $this->assertCanActivate($product);
        }

        if ($newStatus === ProductStatus::Draft && $this->hasOrderLineReferences($product)) {
            throw InvalidProductTransitionException::because('Products with order history cannot be reverted to draft.');
        }

        if (! $this->isAllowedTransition($currentStatus, $newStatus)) {
            throw InvalidProductTransitionException::because("Cannot transition product from {$currentStatus->value} to {$newStatus->value}.");
        }

        $product->forceFill([
            'status' => $newStatus,
            'published_at' => $newStatus === ProductStatus::Active
                ? ($product->published_at ?? now())
                : $product->published_at,
        ])->save();

        ProductStatusChanged::dispatch($product->refresh(), $currentStatus, $newStatus);
    }

    public function delete(Product $product): void
    {
        if ($this->productStatus($product) !== ProductStatus::Draft || $this->hasOrderLineReferences($product)) {
            throw InvalidProductTransitionException::because('Only draft products with no order history can be deleted.');
        }

        $product->delete();
    }

    /**
     * @param  array<string, mixed>  $variantData
     */
    private function createVariant(Product $product, Store $store, array $variantData): ProductVariant
    {
        $this->assertSkuIsUnique($store, $variantData['sku'] ?? null);

        $variant = $product->variants()->create([
            ...Arr::only($variantData, [
                'sku',
                'barcode',
                'price_amount',
                'compare_at_amount',
                'currency',
                'weight_g',
                'requires_shipping',
                'is_default',
                'position',
                'status',
            ]),
            'currency' => $variantData['currency'] ?? $store->default_currency,
            'status' => $variantData['status'] ?? VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $store->getKey(),
                'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
                'quantity_reserved' => 0,
                'policy' => $variantData['inventory_policy'] ?? 'deny',
            ],
        );

        return $variant;
    }

    private function assertCanActivate(Product $product): void
    {
        if (trim((string) $product->title) === '') {
            throw InvalidProductTransitionException::because('A product title is required before activation.');
        }

        if (! $product->variants()->where('price_amount', '>', 0)->exists()) {
            throw InvalidProductTransitionException::because('At least one priced variant is required before activation.');
        }
    }

    private function isAllowedTransition(ProductStatus $from, ProductStatus $to): bool
    {
        return match ($from) {
            ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true),
            ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true),
            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
        };
    }

    private function hasOrderLineReferences(Product $product): bool
    {
        if (! Schema::hasTable('order_lines')) {
            return false;
        }

        $variantIds = $product->variants()->pluck('id');

        if ($variantIds->isEmpty()) {
            return false;
        }

        return DB::table('order_lines')
            ->whereIn('variant_id', $variantIds)
            ->exists();
    }

    private function assertSkuIsUnique(Store $store, ?string $sku, ?int $excludeVariantId = null): void
    {
        if ($sku === null || trim($sku) === '') {
            return;
        }

        $query = ProductVariant::query()
            ->where('sku', $sku)
            ->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey()));

        if ($excludeVariantId !== null) {
            $query->whereKeyNot($excludeVariantId);
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    private function productStatus(Product $product): ProductStatus
    {
        return $product->status instanceof ProductStatus
            ? $product->status
            : ProductStatus::from($product->status);
    }
}


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

<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventoryItem>
 */
class InventoryItemFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'variant_id' => fn (): int => ProductVariant::withoutEvents(
                fn (): int => ProductVariant::factory()->create()->getKey(),
            ),
            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
                ->with('product')
                ->find($attributes['variant_id'])
                ?->product
                ?->store_id ?? Store::factory(),
            'quantity_on_hand' => fake()->numberBetween(0, 100),
            'quantity_reserved' => 0,
            'policy' => InventoryPolicy::Deny,
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => 0,
            'quantity_reserved' => 0,
        ]);
    }

    public function continuePolicy(): static
    {
        return $this->state(fn (array $attributes): array => [
            'policy' => InventoryPolicy::Continue,
        ]);
    }

    public function lowStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => fake()->numberBetween(1, 3),
            'quantity_reserved' => 0,
        ]);
    }
}


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

<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

class VariantMatrixService
{
    public function rebuildMatrix(Product $product): void
    {
        DB::transaction(function () use ($product): void {
            $product->load(['options.values', 'variants.optionValues']);

            $valueGroups = $product->options
                ->sortBy('position')
                ->map(fn ($option) => $option->values->sortBy('position')->values())
                ->filter(fn (Collection $values): bool => $values->isNotEmpty())
                ->values();

            if ($valueGroups->isEmpty()) {
                $this->ensureDefaultVariant($product);

                return;
            }

            $desiredCombinations = $this->cartesianProduct($valueGroups);
            $variantsByCombination = $product->variants->keyBy(function (ProductVariant $variant): string {
                return $this->combinationKey($variant->optionValues->pluck('id')->all());
            });

            $template = $product->variants->sortBy('position')->first();

            foreach ($desiredCombinations as $position => $combination) {
                $key = $this->combinationKey($combination);

                if ($variantsByCombination->has($key)) {
                    continue;
                }

                $variant = $this->createVariantFromTemplate($product, $template, $position);
                $variant->optionValues()->sync($combination);
            }

            $desiredKeys = collect($desiredCombinations)
                ->map(fn (array $combination): string => $this->combinationKey($combination))
                ->all();

            foreach ($product->variants as $variant) {
                $key = $this->combinationKey($variant->optionValues->pluck('id')->all());

                if (in_array($key, $desiredKeys, true)) {
                    continue;
                }

                if ($this->variantHasOrderLines($variant)) {
                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
                } else {
                    $variant->delete();
                }
            }
        });
    }

    private function ensureDefaultVariant(Product $product): void
    {
        if ($product->variants()->exists()) {
            return;
        }

        $variant = $product->variants()->create([
            'price_amount' => 0,
            'currency' => $this->store($product)->default_currency,
            'requires_shipping' => true,
            'is_default' => true,
            'position' => 0,
            'status' => VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $product->store_id,
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ],
        );
    }

    /**
     * @param  Collection<int, Collection<int, object>>  $groups
     * @return array<int, array<int, int>>
     */
    private function cartesianProduct(Collection $groups): array
    {
        return $groups->reduce(
            function (array $carry, Collection $group): array {
                $result = [];

                foreach ($carry as $combination) {
                    foreach ($group as $value) {
                        $result[] = [...$combination, $value->getKey()];
                    }
                }

                return $result;
            },
            [[]],
        );
    }

    /**
     * @param  array<int, int|string>  $ids
     */
    private function combinationKey(array $ids): string
    {
        sort($ids);

        return implode(':', $ids);
    }

    private function createVariantFromTemplate(Product $product, ?ProductVariant $template, int $position): ProductVariant
    {
        $variant = $product->variants()->create([
            'price_amount' => $template?->price_amount ?? 0,
            'compare_at_amount' => $template?->compare_at_amount,
            'currency' => $template?->currency ?? $this->store($product)->default_currency,
            'weight_g' => $template?->weight_g,
            'requires_shipping' => $template?->requires_shipping ?? true,
            'is_default' => false,
            'position' => $position,
            'status' => VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $product->store_id,
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ],
        );

        return $variant;
    }

    private function variantHasOrderLines(ProductVariant $variant): bool
    {
        return Schema::hasTable('order_lines')
            && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists();
    }

    private function store(Product $product): Store
    {
        return Store::query()->findOrFail($product->store_id);
    }
}


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

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

class VariantMatrixService
{
    public function rebuildMatrix(Product $product): void
    {
        DB::transaction(function () use ($product): void {
            $product->load(['options.values', 'variants.optionValues']);

            $valueGroups = $product->options
                ->sortBy('position')
                ->map(fn ($option) => $option->values->sortBy('position')->values())
                ->filter(fn (Collection $values): bool => $values->isNotEmpty())
                ->values();

            if ($valueGroups->isEmpty()) {
                $this->ensureDefaultVariant($product);

                return;
            }

            $desiredCombinations = $this->cartesianProduct($valueGroups);
            $variantsByCombination = $product->variants->keyBy(function (ProductVariant $variant): string {
                return $this->combinationKey($variant->optionValues->pluck('id')->all());
            });

            $template = $product->variants->sortBy('position')->first();

            foreach ($desiredCombinations as $position => $combination) {
                $key = $this->combinationKey($combination);

                if ($variantsByCombination->has($key)) {
                    continue;
                }

                $variant = $this->createVariantFromTemplate($product, $template, $position);
                $variant->optionValues()->sync($combination);
            }

            $desiredKeys = collect($desiredCombinations)
                ->map(fn (array $combination): string => $this->combinationKey($combination))
                ->all();

            foreach ($product->variants as $variant) {
                $key = $this->combinationKey($variant->optionValues->pluck('id')->all());

                if (in_array($key, $desiredKeys, true)) {
                    continue;
                }

                if ($this->variantHasOrderLines($variant)) {
                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
                } else {
                    $variant->delete();
                }
            }
        });
    }

    private function ensureDefaultVariant(Product $product): void
    {
        if ($product->variants()->exists()) {
            return;
        }

        $variant = $product->variants()->create([
            'price_amount' => 0,
            'currency' => $this->store($product)->default_currency,
            'requires_shipping' => true,
            'is_default' => true,
            'position' => 0,
            'status' => VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $product->store_id,
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ],
        );
    }

    /**
     * @param  Collection<int, Collection<int, object>>  $groups
     * @return array<int, array<int, int>>
     */
    private function cartesianProduct(Collection $groups): array
    {
        return $groups->reduce(
            function (array $carry, Collection $group): array {
                $result = [];

                foreach ($carry as $combination) {
                    foreach ($group as $value) {
                        $result[] = [...$combination, $value->getKey()];
                    }
                }

                return $result;
            },
            [[]],
        );
    }

    /**
     * @param  array<int, int|string>  $ids
     */
    private function combinationKey(array $ids): string
    {
        sort($ids);

        return implode(':', $ids);
    }

    private function createVariantFromTemplate(Product $product, ?ProductVariant $template, int $position): ProductVariant
    {
        $variant = $product->variants()->create([
            'price_amount' => $template?->price_amount ?? 0,
            'compare_at_amount' => $template?->compare_at_amount,
            'currency' => $template?->currency ?? $this->store($product)->default_currency,
            'weight_g' => $template?->weight_g,
            'requires_shipping' => $template?->requires_shipping ?? true,
            'is_default' => false,
            'position' => $position,
            'status' => VariantStatus::Active,
        ]);

        InventoryItem::withoutGlobalScopes()->updateOrCreate(
            ['variant_id' => $variant->getKey()],
            [
                'store_id' => $product->store_id,
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ],
        );

        return $variant;
    }

    private function variantHasOrderLines(ProductVariant $variant): bool
    {
        return Schema::hasTable('order_lines')
            && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists();
    }

    private function store(Product $product): Store
    {
        return Store::query()->findOrFail($product->store_id);
    }
}


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

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventoryItem>
 */
class InventoryItemFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'variant_id' => fn (): int => ProductVariant::withoutEvents(
                fn (): int => ProductVariant::factory()->create()->getKey(),
            ),
            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
                ->with('product')
                ->find($attributes['variant_id'])
                ?->product
                ?->store_id ?? Store::factory(),
            'quantity_on_hand' => fake()->numberBetween(0, 100),
            'quantity_reserved' => 0,
            'policy' => InventoryPolicy::Deny,
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => 0,
            'quantity_reserved' => 0,
        ]);
    }

    public function continuePolicy(): static
    {
        return $this->state(fn (array $attributes): array => [
            'policy' => InventoryPolicy::Continue,
        ]);
    }

    public function lowStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => fake()->numberBetween(1, 3),
            'quantity_reserved' => 0,
        ]);
    }
}


Output:
Chunk ID: 024d90
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 606
Output:
<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Archived,
        ]);
    }

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            $variant = ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);

            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
                'quantity_on_hand' => 50,
            ]);
        });
    }
}


The inventory event and factory guard are already partly in place in the worktree. I’m tightening the remaining enum casts/defaults and then I’ll update tests to assert the invariant directly instead of relying on duplicate manual inventory rows.


sed -n '1,260p' tests/Feature/Catalog/CatalogFoundationTest.php


sed -n '1,260p' tests/Feature/Catalog/ProductServiceTest.php


sed -n '1,260p' tests/Feature/Catalog/InventoryServiceTest.php


sed -n '1,280p' tests/Feature/Catalog/VariantMatrixServiceTest.php


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


sed -n '1,260p' tests/Feature/Catalog/CatalogFoundationTest.php

<?php

use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;

uses(RefreshDatabase::class);

test('catalog tables and seeded fixtures exist', function () {
    $this->seed(DatabaseSeeder::class);

    expect(Schema::hasColumns('products', ['store_id', 'title', 'handle', 'status', 'tags']))->toBeTrue()
        ->and(Schema::hasColumns('product_variants', ['product_id', 'sku', 'price_amount', 'requires_shipping']))->toBeTrue()
        ->and(Schema::hasColumns('inventory_items', ['store_id', 'variant_id', 'quantity_on_hand', 'quantity_reserved', 'policy']))->toBeTrue()
        ->and(Schema::hasColumns('collections', ['store_id', 'title', 'handle', 'type', 'status']))->toBeTrue()
        ->and(Product::withoutGlobalScopes()->count())->toBe(20)
        ->and(Collection::withoutGlobalScopes()->where('handle', 't-shirts')->exists())->toBeTrue()
        ->and(Product::withoutGlobalScopes()->where('handle', 'classic-cotton-t-shirt')->first()?->variants()->count())->toBe(3)
        ->and(Product::withoutGlobalScopes()->where('handle', 'sold-out-canvas-tote')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('deny')
        ->and(Product::withoutGlobalScopes()->where('handle', 'backorder-wool-scarf')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('continue');
});

test('catalog models are scoped to the resolved store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();

    Product::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-store-product']);
    Product::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-store-product']);

    app()->instance('current_store', $firstStore);

    expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']);
});


sed -n '1,260p' tests/Feature/Catalog/ProductServiceTest.php

<?php

use App\Enums\ProductStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\HandleGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;

uses(RefreshDatabase::class);

test('handle generator creates unique handles per store', function () {
    $store = Store::factory()->create();

    Product::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Classic Cotton T-Shirt',
        'handle' => 'classic-cotton-t-shirt',
    ]);

    $handle = app(HandleGenerator::class)->generate('Classic Cotton T-Shirt', 'products', $store->getKey());

    expect($handle)->toBe('classic-cotton-t-shirt-1');
});

test('product service creates products with a default variant and inventory', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Merino Crew',
        'price_amount' => 4999,
        'tags' => ['new'],
    ]);

    expect($product->handle)->toBe('merino-crew')
        ->and($product->variants)->toHaveCount(1)
        ->and($product->variants->first()->price_amount)->toBe(4999)
        ->and($product->variants->first()->inventoryItem)->not->toBeNull();
});

test('product status transitions enforce activation preconditions and dispatch events', function () {
    Event::fake([ProductStatusChanged::class]);

    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->draft()->create([
        'store_id' => $store->getKey(),
        'title' => 'Draft Product',
    ]);

    expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active))
        ->toThrow(InvalidProductTransitionException::class);

    ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 1200,
    ]);

    app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active);

    expect($product->refresh()->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();

    Event::assertDispatched(ProductStatusChanged::class);
});

test('duplicate non-empty skus are rejected within the same store', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    app(ProductService::class)->create($store, [
        'title' => 'First Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1000],
        ],
    ]);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Second Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1200],
        ],
    ]))->toThrow(RuntimeException::class);
});


sed -n '1,260p' tests/Feature/Catalog/InventoryServiceTest.php

<?php

use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\InventoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('inventory reserve release commit and restock update quantities transactionally', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
    $item = $variant->inventoryItem()->firstOrFail();
    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();

    $service = app(InventoryService::class);

    $service->reserve($item, 4);
    expect($item->refresh()->quantity_reserved)->toBe(4);

    $service->release($item, 1);
    expect($item->refresh()->quantity_reserved)->toBe(3);

    $service->commit($item, 2);
    expect($item->refresh()->quantity_on_hand)->toBe(8)
        ->and($item->quantity_reserved)->toBe(1);

    $service->restock($item, 5);
    expect($item->refresh()->quantity_on_hand)->toBe(13);
});

test('deny policy blocks reservations above available stock while continue allows it', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $denyItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 1,
        'quantity_reserved' => 0,
        'policy' => 'deny',
    ]);

    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
        ->toThrow(InsufficientInventoryException::class);

    $continueItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 0,
        'quantity_reserved' => 0,
        'policy' => 'continue',
    ]);

    app(InventoryService::class)->reserve($continueItem, 2);

    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
});


sed -n '1,280p' tests/Feature/Catalog/VariantMatrixServiceTest.php

<?php

use App\Models\Product;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\VariantMatrixService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);

    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);

    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);

    $variant = ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 2500,
    ]);
    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(4)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
});

test('variant matrix rebuild creates a default variant when there are no options', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $product->variants()->delete();

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(1)
        ->and($product->variants()->first()->is_default)->toBeTrue();
});


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

<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => VariantStatus::Archived,
        ]);
    }
}


Output:
Chunk ID: 3d38cd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 497
Output:
<?php

use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;

uses(RefreshDatabase::class);

test('catalog tables and seeded fixtures exist', function () {
    $this->seed(DatabaseSeeder::class);

    expect(Schema::hasColumns('products', ['store_id', 'title', 'handle', 'status', 'tags']))->toBeTrue()
        ->and(Schema::hasColumns('product_variants', ['product_id', 'sku', 'price_amount', 'requires_shipping']))->toBeTrue()
        ->and(Schema::hasColumns('inventory_items', ['store_id', 'variant_id', 'quantity_on_hand', 'quantity_reserved', 'policy']))->toBeTrue()
        ->and(Schema::hasColumns('collections', ['store_id', 'title', 'handle', 'type', 'status']))->toBeTrue()
        ->and(Product::withoutGlobalScopes()->count())->toBe(20)
        ->and(Collection::withoutGlobalScopes()->where('handle', 't-shirts')->exists())->toBeTrue()
        ->and(Product::withoutGlobalScopes()->where('handle', 'classic-cotton-t-shirt')->first()?->variants()->count())->toBe(3)
        ->and(Product::withoutGlobalScopes()->where('handle', 'sold-out-canvas-tote')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('deny')
        ->and(Product::withoutGlobalScopes()->where('handle', 'backorder-wool-scarf')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('continue');
});

test('catalog models are scoped to the resolved store', function () {
    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();

    Product::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-store-product']);
    Product::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-store-product']);

    app()->instance('current_store', $firstStore);

    expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']);
});


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

use App\Enums\ProductStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\HandleGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;

uses(RefreshDatabase::class);

test('handle generator creates unique handles per store', function () {
    $store = Store::factory()->create();

    Product::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Classic Cotton T-Shirt',
        'handle' => 'classic-cotton-t-shirt',
    ]);

    $handle = app(HandleGenerator::class)->generate('Classic Cotton T-Shirt', 'products', $store->getKey());

    expect($handle)->toBe('classic-cotton-t-shirt-1');
});

test('product service creates products with a default variant and inventory', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Merino Crew',
        'price_amount' => 4999,
        'tags' => ['new'],
    ]);

    expect($product->handle)->toBe('merino-crew')
        ->and($product->variants)->toHaveCount(1)
        ->and($product->variants->first()->price_amount)->toBe(4999)
        ->and($product->variants->first()->inventoryItem)->not->toBeNull();
});

test('product status transitions enforce activation preconditions and dispatch events', function () {
    Event::fake([ProductStatusChanged::class]);

    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->draft()->create([
        'store_id' => $store->getKey(),
        'title' => 'Draft Product',
    ]);

    expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active))
        ->toThrow(InvalidProductTransitionException::class);

    ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 1200,
    ]);

    app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active);

    expect($product->refresh()->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();

    Event::assertDispatched(ProductStatusChanged::class);
});

test('duplicate non-empty skus are rejected within the same store', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    app(ProductService::class)->create($store, [
        'title' => 'First Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1000],
        ],
    ]);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Second Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1200],
        ],
    ]))->toThrow(RuntimeException::class);
});


Output:
Chunk ID: 294d86
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 528
Output:
<?php

use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\InventoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('inventory reserve release commit and restock update quantities transactionally', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
    $item = $variant->inventoryItem()->firstOrFail();
    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();

    $service = app(InventoryService::class);

    $service->reserve($item, 4);
    expect($item->refresh()->quantity_reserved)->toBe(4);

    $service->release($item, 1);
    expect($item->refresh()->quantity_reserved)->toBe(3);

    $service->commit($item, 2);
    expect($item->refresh()->quantity_on_hand)->toBe(8)
        ->and($item->quantity_reserved)->toBe(1);

    $service->restock($item, 5);
    expect($item->refresh()->quantity_on_hand)->toBe(13);
});

test('deny policy blocks reservations above available stock while continue allows it', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $denyItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 1,
        'quantity_reserved' => 0,
        'policy' => 'deny',
    ]);

    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
        ->toThrow(InsufficientInventoryException::class);

    $continueItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 0,
        'quantity_reserved' => 0,
        'policy' => 'continue',
    ]);

    app(InventoryService::class)->reserve($continueItem, 2);

    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
});


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

use App\Models\Product;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\VariantMatrixService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);

    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);

    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);

    $variant = ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 2500,
    ]);
    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(4)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
});

test('variant matrix rebuild creates a default variant when there are no options', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $product->variants()->delete();

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(1)
        ->and($product->variants()->first()->is_default)->toBeTrue();
});


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

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => VariantStatus::Archived,
        ]);
    }
}


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


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


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


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

<?php

namespace App\Models;

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

class InventoryItem extends Model
{
    /** @use HasFactory<\Database\Factories\InventoryItemFactory> */
    use BelongsToStore, HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'variant_id',
        'quantity_on_hand',
        'quantity_reserved',
        'policy',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'quantity_on_hand' => 0,
        'quantity_reserved' => 0,
        'policy' => 'deny',
    ];

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

    /**
     * @return BelongsTo<ProductVariant, $this>
     */
    public function variant(): BelongsTo
    {
        return $this->belongsTo(ProductVariant::class, 'variant_id');
    }

    public function availableQuantity(): int
    {
        return $this->quantity_on_hand - $this->quantity_reserved;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'quantity_on_hand' => 'integer',
            'quantity_reserved' => 'integer',
            'policy' => InventoryPolicy::class,
        ];
    }
}


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

<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

class InventoryService
{
    public function checkAvailability(InventoryItem $item, int $quantity): bool
    {
        $this->assertPositiveQuantity($quantity);

        if ($item->policy === InventoryPolicy::Continue) {
            return true;
        }

        return $this->available($item) >= $quantity;
    }

    public function reserve(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $locked = $this->freshItem($item);

            if (! $this->checkAvailability($locked, $quantity)) {
                throw InsufficientInventoryException::forQuantity($this->available($locked), $quantity);
            }

            $locked->forceFill([
                'quantity_reserved' => $locked->quantity_reserved + $quantity,
            ])->save();
        });
    }

    public function release(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $locked = $this->freshItem($item);

            if ($locked->quantity_reserved < $quantity) {
                throw new InvalidArgumentException('Cannot release more inventory than is reserved.');
            }

            $locked->forceFill([
                'quantity_reserved' => $locked->quantity_reserved - $quantity,
            ])->save();
        });
    }

    public function commit(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $locked = $this->freshItem($item);

            if ($locked->quantity_reserved < $quantity) {
                throw new InvalidArgumentException('Cannot commit more inventory than is reserved.');
            }

            $locked->forceFill([
                'quantity_on_hand' => $locked->quantity_on_hand - $quantity,
                'quantity_reserved' => $locked->quantity_reserved - $quantity,
            ])->save();
        });
    }

    public function restock(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $locked = $this->freshItem($item);

            $locked->forceFill([
                'quantity_on_hand' => $locked->quantity_on_hand + $quantity,
            ])->save();
        });
    }

    private function available(InventoryItem $item): int
    {
        return $item->quantity_on_hand - $item->quantity_reserved;
    }

    private function freshItem(InventoryItem $item): InventoryItem
    {
        return InventoryItem::query()
            ->whereKey($item->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function assertPositiveQuantity(int $quantity): void
    {
        if ($quantity <= 0) {
            throw new InvalidArgumentException('Inventory quantity must be greater than zero.');
        }
    }
}


/bin/zsh
-lc
sed -n '1,260p' specs/progress.md

# Shop Build Progress

## Objective

Build a complete, self-contained Laravel shop system from `specs/*`, with implemented acceptance criteria mapped to evidence, passing automated checks, verified browser flows, independent QA, and meaningful commits.

## Current Status

- Status: in progress
- Active slice: Phase 1 - Foundation, tenancy, core auth, authorization
- Started from: Laravel Livewire/Fortify starter kit with no shop domain tables or models present
- Last updated: 2026-05-03

## Phased Plan

1. Foundation: SQLite config, tenancy tables/models/factories/seeders, store resolution middleware, store scoping, admin/customer auth foundations, policies, and Phase 1 tests.
2. Catalog: product, variant, inventory, collection, media schema/models/services, admin catalog CRUD, storefront browsing, and tests.
3. Storefront theme/layout: theme/page/navigation data, storefront layout, product/collection/search/page views, and smoke browser verification.
4. Cart/checkout/pricing: cart API/UI, discount, shipping, tax, checkout state machine, and tests.
5. Payments/orders/fulfillment: mock PSP, order creation, refunds, fulfillments, customer account order views, and tests.
6. Admin panel: dashboard and all admin list/form/detail pages from Spec 03, with Livewire/Flux interactions.
7. Search/analytics/apps/webhooks: SQLite search, event aggregation, developer token/webhook UI, delivery jobs, and tests.
8. Final verification: full Pest suite, Pint, frontend build, Playwright MCP customer/admin flows, independent QA, fixes, and completion audit.

## Acceptance Checklist

| Area | Criteria Source | Status | Evidence |
| --- | --- | --- | --- |
| Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables implemented: organizations, stores, store_domains, store_users, store_settings, customers, customer_password_reset_tokens, password_hash users. Boost schema confirmed these tables after `migrate:fresh --seed`. |
| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 routes exist: `/`, `/admin`, `/admin/login`, `/admin/logout`, `/account/login`, `/account/register`, `/account`. API routes are still missing. |
| Admin UI | `specs/03-ADMIN-UI.md` | missing | Starter dashboard/settings only. |
| Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront home still starter welcome; customer login/register/account placeholders exist. |
| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, and customer guard provider implemented. Catalog/cart/checkout/order services still missing. |
| Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout, customer guard/provider, customer login/register, login rate limiting, session hardening, and resource policies implemented. Customer password reset and Sanctum token auth still missing. |
| Seed/test data | `specs/07-SEEDERS-AND-TEST-DATA.md` | partial | Phase 1 seeders create Acme organization/store/domains, owner admin, store settings, and test customer. Full catalog/order/discount/theme seed data still missing. |
| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | missing | No browser tests yet. |
| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation substantially implemented; next blocking slice is catalog schema/models/services. |

## Verification Evidence

- 2026-05-03: `mcp__laravel_boost__.application_info` confirmed PHP 8.4, Laravel 12.51.0, Livewire 4.1.4, Flux 2.12.0, Pest 4.3.2, SQLite.
- 2026-05-03: `mcp__laravel_boost__.database_schema(summary: true)` returned no tables for the current configured database.
- 2026-05-03: `git status --short` showed no current worktree changes before implementation.
- 2026-05-03: `mcp__laravel_boost__.search_docs` consulted Laravel 12 middleware/auth/authorization/migration docs plus Livewire 4 and Pest 4 docs before code changes.
- 2026-05-03: Planning challenger sub-agent found the repo was still a starter kit and produced the acceptance checklist by vertical slice. Risks added here: missing Sanctum dependency, password_hash/Fortify coordination, route/API absence, and E2E count inconsistencies.
- 2026-05-03: `php artisan migrate:fresh --seed --no-interaction` passed on real `database/database.sqlite` after creating the missing SQLite file.
- 2026-05-03: `php artisan test --compact` passed: 45 tests, 121 assertions.
- 2026-05-03: `vendor/bin/pint --dirty --format agent` passed after formatting changes.
- 2026-05-03: `php artisan route:list --path=admin` showed `/admin`, `/admin/login`, `/admin/logout`; `php artisan route:list --path=account` showed `/account`, `/account/login`, `/account/register`.
- 2026-05-03: Boost schema summary confirmed Phase 1 tables: organizations, stores, store_domains, store_users, store_settings, customers, customer_password_reset_tokens, users with password_hash.
- 2026-05-03: Playwright MCP verified `http://shop.test/` loads, `http://shop.test/admin/login` renders, admin login with `admin@acme.test` / `password` reaches `/admin`, `http://shop.test/account/login` renders with no console warnings/errors after the Livewire layout fix, and customer login with `customer@acme.test` / `password` reaches `/account`.

## Decisions

- Follow the roadmap order strictly. Phase 1 is required before catalog, checkout, admin, storefront, and QA flows can be meaningfully implemented.
- Preserve the starter Fortify conventions where they do not conflict with the shop specs.
- Use integer minor units for all money columns and JSON text columns cast through Eloquent as specified.
- Keep `password_hash` as the database column for admin users and customers while exposing `password`/`getAuthPassword()` at the model layer for Fortify/Laravel auth compatibility.
- Seed both `shop.test` and `acme-fashion.test` as storefront domains for the first store so the goal URL and E2E spec URL can both resolve.
- Customer Livewire auth components persist `storeId` from the initial storefront request because Livewire update requests do not run through the original storefront route middleware.

## Open Issues

- Composer does not currently include Sanctum, while Spec 06 requires Sanctum personal access tokens. This will need either an approved dependency addition or a documented compatible alternative before final completion.
- The Herd app URL in the goal is `http://shop.test/`, while the E2E spec also references `http://acme-fashion.test`; domain handling must support seeded store domains and Herd verification.
- No critical/high QA can be claimed until independent QA agents inspect the implemented slices.
- Phase 1 still lacks admin/customer password reset at the spec paths and a custom customer password reset token repository that scopes by store_id.
- Policy classes for later resources use generic object parameters until the concrete catalog/order/content models exist.
- `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.

## Completion Summary

Not complete. Phase 1 foundation is implemented enough to support the next catalog slice, with known auth/token gaps tracked above.