message: Both QA agents agree the catalog data layer is passing tests but still has real high-risk gaps. I’m going to harden the model/service invariants now: product lifecycle must go through transitions, child records need tenant-aware access paths, and inventory rows must stay tied to their variant’s store.


type: message

role: assistant

text: Both QA agents agree the catalog data layer is passing tests but still has real high-risk gaps. I’m going to harden the model/service invariants now: product lifecycle must go through transitions, child records need tenant-aware access paths, and inventory rows must stay tied to their variant’s store.

phase: commentary


packages: laravel/framework pestphp/pest

queries: eloquent model events creating saving validation throw exception

queries: migration check constraint sqlite raw check

queries: eloquent relationships through parent tenant scope tests


server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Migrating From Phpunit Guide
Migrating From Phpunit Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/migrating-from-phpunit-guide.md

---
title: Migrating from PHPUnit
description: Migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps.
---

# Migrating from PHPUnit

Pest is built on top of PHPUnit, so migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps. Once you have Pest installed, you should require the `pestphp/pest-plugin-drift` package as a "dev" dependency in your project.

```bash
composer require pestphp/pest-plugin-drift --dev
```

Drift is a simple yet powerful plugin that will automatically convert your PHPUnit tests to Pest, simply by running the `--drift` option.

```bash
./vendor/bin/pest --drift
```

So, typically, a PHPUnit test looks like this:

```php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function test_that_true_is_true(): void
    {
        $this->assertTrue(true);
    }
}
```

Should look like this after running `--drift`:

```php
test('true is true', function () {
    expect(true)->toBeTrue();
});
```

---
## Convert Phpunit tests only for certain folder

If you want to convert Phpunit tests only for certain folder, you can pass _path_ as first argument when calling `--drift`:

Example call, if you want to run the conversion for folder `/tests/Helpers`:
```console
/vendor/bin/pest --drift tests/Helpers
```

Output:
```console
/vendor/bin/pest --drift tests/Helpers

✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔

INFO  The [tests/Helpers] directory has been migrated to PEST with XY files changed.
```

---

The output will contain a summary of the conversion process, as well as a list of the files that were converted.

While most of your tests should be converted automatically, and you should be able to run them without any issues, there are some cases where you may need to manually convert some of your tests.

---

Of course, this particular chapter is only for those who are migrating from PHPUnit. Next, let's learn how you can contribute to the growth of Pest: [Community Guide](/docs/community-guide)

---

## Urls URLs for Named Routes Eloquent Models
Urls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md

#### Eloquent Models

You 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:

```php
echo route('post.show', ['post' => $post]);
```

---

## Migrations Indexes Foreign Key Constraints Toggling Foreign Key Constraints
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

#### Toggling Foreign Key Constraints

You may enable or disable foreign key constraints within your migrations by using the following methods:

```php
Schema::enableForeignKeyConstraints();

Schema::disableForeignKeyConstraints();

Schema::withoutForeignKeyConstraints(function () {
    // Constraints disabled within this closure...
});
```

> [!WARNING]
> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations.

---

## Eloquent Events Using Closures
Eloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md

### Using Closures

Instead 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:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * The "booted" method of the model.
     */
    protected static function booted(): void
    {
        static::created(function (User $user) {
            // ...
        });
    }
}
```

If 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):

```php
use function Illuminate\Events\queueable;

static::created(queueable(function (User $user) {
    // ...
}));
```

---

## Packages Resources Migrations
Packages | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/packages.md

### Migrations

If your package contains [database migrations](/docs/{{version}}/migrations), you may use the `publishesMigrations` method to inform Laravel that the given directory or file contains migrations. When Laravel publishes the migrations, it will automatically update the timestamp within their filename to reflect the current date and time:

```php
/**
 * Bootstrap any package services.
 */
public function boot(): void
{
    $this->publishesMigrations([
        __DIR__.'/../database/migrations' => database_path('migrations'),
    ]);
}
```

---

## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7
Upgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md

#### Models and UUIDv7

**Likelihood Of Impact: Medium**

The `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:

```php
use Illuminate\Database\Eloquent\Concerns\HasUuids; // [tl! remove]
use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids; // [tl! add]
```

The `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.

---

## Eloquent Relationships
Eloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md

# Eloquent: Relationships

- [Introduction](#introduction)
- [Defining Relationships](#defining-relationships)
    - [One to One / Has One](#one-to-one)
    - [One to Many / Has Many](#one-to-many)
    - [One to Many (Inverse) / Belongs To](#one-to-many-inverse)
    - [Has One of Many](#has-one-of-many)
    - [Has One Through](#has-one-through)
    - [Has Many Through](#has-many-through)
- [Scoped Relationships](#scoped-relationships)
- [Many to Many Relationships](#many-to-many)
    - [Retrieving Intermediate Table Columns](#retrieving-intermediate-table-columns)
    - [Filtering Queries via Intermediate Table Columns](#filtering-queries-via-intermediate-table-columns)
    - [Ordering Queries via Intermediate Table Columns](#ordering-queries-via-intermediate-table-columns)
    - [Defining Custom Intermediate Table Models](#defining-custom-intermediate-table-models)
- [Polymorphic Relationships](#polymorphic-relationships)
    - [One to One](#one-to-one-polymorphic-relations)
    - [One to Many](#one-to-many-polymorphic-relations)
    - [One of Many](#one-of-many-polymorphic-relations)
    - [Many to Many](#many-to-many-polymorphic-relations)
    - [Custom Polymorphic Types](#custom-polymorphic-types)
- [Dynamic Relationships](#dynamic-relationships)
- [Querying Relations](#querying-relations)
    - [Relationship Methods vs. Dynamic Properties](#relationship-methods-vs-dynamic-properties)
    - [Querying Relationship Existence](#querying-relationship-existence)
    - [Querying Relationship Absence](#querying-relationship-absence)
    - [Querying Morph To Relationships](#querying-morph-to-relationships)
- [Aggregating Related Models](#aggregating-related-models)
    - [Counting Related Models](#counting-related-models)
    - [Other Aggregate Functions](#other-aggregate-functions)
    - [Counting Related Models on Morph To Relationships](#counting-related-models-on-morph-to-relationships)
- [Eager Loading](#eager-loading)
    - [Constraining Eager Loads](#constraining-eager-loads)
    - [Lazy Eager Loading](#lazy-eager-loading)
    - [Automatic Eager Loading](#automatic-eager-loading)
    - [Preventing Lazy Loading](#preventing-lazy-loading)
- [Inserting and Updating Related Models](#inserting-and-updating-related-models)
    - [The `save` Method](#the-save-method)
    - [The `create` Method](#the-create-method)
    - [Belongs To Relationships](#updating-belongs-to-relationships)
    - [Many to Many Relationships](#updating-many-to-many-relationships)
- [Touching Parent Timestamps](#touching-parent-timestamps)

---

## Migrations Indexes Foreign Key Constraints
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

### Foreign Key Constraints

Laravel also provides support for creating foreign key constraints, which are used to force referential integrity at the database level. For example, let's define a `user_id` column on the `posts` table that references the `id` column on a `users` table:

```php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('posts', function (Blueprint $table) {
    $table->unsignedBigInteger('user_id');

    $table->foreign('user_id')->references('id')->on('users');
});
```

Since this syntax is rather verbose, Laravel provides additional, terser methods that use conventions to provide a better developer experience. When using the `foreignId` method to create your column, the example above can be rewritten like so:

```php
Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained();
});
```

The `foreignId` method creates an `UNSIGNED BIGINT` equivalent column, while the `constrained` method will use conventions to determine the table and column being referenced. If your table name does not match Laravel's conventions, you may manually provide it to the `constrained` method. In addition, the name that should be assigned to the generated index may be specified as well:

```php
Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained(
        table: 'users', indexName: 'posts_user_id'
    );
});
```

You may also specify the desired action for the "on delete" and "on update" properties of the constraint:

```php
$table->foreignId('user_id')
    ->constrained()
    ->onUpdate('cascade')
    ->onDelete('cascade');
```

An alternative, expressive syntax is also provided for these actions:

| Method                        | Description                                       |
| ----------------------------- | ------------------------------------------------- |
| `$table->cascadeOnUpdate();`  | Updates should cascade.                           |
| `$table->restrictOnUpdate();` | Updates should be restricted.                     |
| `$table->nullOnUpdate();`     | Updates should set the foreign key value to null. |
| `$table->noActionOnUpdate();` | No action on updates.                             |
| `$table->cascadeOnDelete();`  | Deletes should cascade.                           |
| `$table->restrictOnDelete();` | Deletes should be restricted.                     |
| `$table->nullOnDelete();`     | Deletes should set the foreign key value to null. |
| `$table->noActionOnDelete();` | Prevents deletes if child records exist.          |

Any additional [column modifiers](#column-modifiers) must be called before the `constrained` method:

```php
$table->foreignId('user_id')
    ->nullable()
    ->constrained();
```

---

## Migrations Events
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

## Events

For convenience, each migration operation will dispatch an [event](/docs/{{version}}/events). All of the following events extend the base `Illuminate\Database\Events\MigrationEvent` class:

| Class                                            | Description                                      |
| ------------------------------------------------ | ------------------------------------------------ |
| `Illuminate\Database\Events\MigrationsStarted`   | A batch of migrations is about to be executed.   |
| `Illuminate\Database\Events\MigrationsEnded`     | A batch of migrations has finished executing.    |
| `Illuminate\Database\Events\MigrationStarted`    | A single migration is about to be executed.      |
| `Illuminate\Database\Events\MigrationEnded`      | A single migration has finished executing.       |
| `Illuminate\Database\Events\NoPendingMigrations` | A migration command found no pending migrations. |
| `Illuminate\Database\Events\SchemaDumped`        | A database schema dump has completed.            |
| `Illuminate\Database\Events\SchemaLoaded`        | An existing database schema dump has loaded.     |

---

## Scout Searching Where Clauses Customizing the Eloquent Results Query
Scout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md

#### Customizing the Eloquent Results Query

After 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:

```php
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;

$orders = Order::search('Star Trek')
    ->query(fn (Builder $query) => $query->with('invoices'))
    ->get();
```

When 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.

---

## Eloquent Events
Eloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md

## Events

> [!NOTE]
> Want to broadcast your Eloquent events directly to your client-side application? Check out Laravel's [model event broadcasting](/docs/{{version}}/broadcasting#model-broadcasting).

Eloquent models dispatch several events, allowing you to hook into the following moments in a model's lifecycle: `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `trashed`, `forceDeleting`, `forceDeleted`, `restoring`, `restored`, and `replicating`.

The `retrieved` event will dispatch when an existing model is retrieved from the database. When a new model is saved for the first time, the `creating` and `created` events will dispatch. The `updating` / `updated` events will dispatch when an existing model is modified and the `save` method is called. The `saving` / `saved` events will dispatch when a model is created or updated - even if the model's attributes have not been changed. Event names ending with `-ing` are dispatched before any changes to the model are persisted, while events ending with `-ed` are dispatched after the changes to the model are persisted.

To start listening to model events, define a `$dispatchesEvents` property on your Eloquent model. This property maps various points of the Eloquent model's lifecycle to your own [event classes](/docs/{{version}}/events). Each model event class should expect to receive an instance of the affected model via its constructor:

```php
<?php

namespace App\Models;

use App\Events\UserDeleted;
use App\Events\UserSaved;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The event map for the model.
     *
     * @var array<string, string>
     */
    protected $dispatchesEvents = [
        'saved' => UserSaved::class,
        'deleted' => UserDeleted::class,
    ];
}
```

After defining and mapping your Eloquent events, you may use [event listeners](/docs/{{version}}/events#defining-listeners) to handle the events.

> [!WARNING]
> When issuing a mass update or delete query via Eloquent, the `saved`, `updated`, `deleting`, and `deleted` model events will not be dispatched for the affected models. This is because the models are never actually retrieved when performing mass updates or deletes.

---

## Migrations Indexes Foreign Key Constraints Dropping Foreign Keys
Migrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md

#### Dropping Foreign Keys

To drop a foreign key, you may use the `dropForeign` method, passing the name of the foreign key constraint to be deleted as an argument. Foreign key constraints use the same naming convention as indexes. In other words, the foreign key constraint name is based on the name of the table and the columns in the constraint, followed by a "\_foreign" suffix:

```php
$table->dropForeign('posts_user_id_foreign');
```

Alternatively, you may pass an array containing the column name that holds the foreign key to the `dropForeign` method. The array will be converted to a foreign key constraint name using Laravel's constraint naming conventions:

```php
$table->dropForeign(['user_id']);
```

---

## Eloquent Events Muting Events Saving a Single Model Without Events
Eloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md

#### Saving a Single Model Without Events

Sometimes you may wish to "save" a given model without dispatching any events. You may accomplish this using the `saveQuietly` method:

```php
$user = User::findOrFail(1);

$user->name = 'Victoria Faith';

$user->saveQuietly();
```

You may also "update", "delete", "soft delete", "restore", and "replicate" a given model without dispatching any events:

```php
$user->deleteQuietly();
$user->forceDeleteQuietly();
$user->restoreQuietly();
```

---

## Responses Redirects Redirecting to Named Routes Populating Parameters via Eloquent Models
Responses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md

#### Populating Parameters via Eloquent Models

If 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:

```php
// For a route with the following URI: /profile/{id}

return redirect()->route('profile', [$user]);
```

If 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:

```php
/**
 * Get the value of the model's route key.
 */
public function getRouteKey(): mixed
{
    return $this->slug;
}
```

---

## Eloquent Relationships Introduction
Eloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md

## Introduction

Database tables are often related to one another. For example, a blog post may have many comments or an order could be related to the user who placed it. Eloquent makes managing and working with these relationships easy, and supports a variety of common relationships:

- [One To One](#one-to-one)
- [One To Many](#one-to-many)
- [Many To Many](#many-to-many)
- [Has One Through](#has-one-through)
- [Has Many Through](#has-many-through)
- [One To One (Polymorphic)](#one-to-one-polymorphic-relations)
- [One To Many (Polymorphic)](#one-to-many-polymorphic-relations)
- [Many To Many (Polymorphic)](#many-to-many-polymorphic-relations)

_[Truncated]_


output:
Wall time: 3.6697 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Migrating From Phpunit Guide\nMigrating From Phpunit Guide | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/migrating-from-phpunit-guide.md\n\n---\ntitle: Migrating from PHPUnit\ndescription: Migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps.\n---\n\n# Migrating from PHPUnit\n\nPest is built on top of PHPUnit, so migrating from PHPUnit to Pest is a simple process that can be completed in just a few steps. Once you have Pest installed, you should require the `pestphp/pest-plugin-drift` package as a \"dev\" dependency in your project.\n\n```bash\ncomposer require pestphp/pest-plugin-drift --dev\n```\n\nDrift is a simple yet powerful plugin that will automatically convert your PHPUnit tests to Pest, simply by running the `--drift` option.\n\n```bash\n./vendor/bin/pest --drift\n```\n\nSo, typically, a PHPUnit test looks like this:\n\n```php\n<?php\n\nnamespace Tests\\Unit;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_that_true_is_true(): void\n    {\n        $this->assertTrue(true);\n    }\n}\n```\n\nShould look like this after running `--drift`:\n\n```php\ntest('true is true', function () {\n    expect(true)->toBeTrue();\n});\n```\n\n---\n## Convert Phpunit tests only for certain folder\n\nIf you want to convert Phpunit tests only for certain folder, you can pass _path_ as first argument when calling `--drift`:\n\nExample call, if you want to run the conversion for folder `/tests/Helpers`: \n```console \n/vendor/bin/pest --drift tests/Helpers\n```\n\nOutput:\n```console\n/vendor/bin/pest --drift tests/Helpers\n\n✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔\n\nINFO  The [tests/Helpers] directory has been migrated to PEST with XY files changed.\n```\n\n---\n\nThe output will contain a summary of the conversion process, as well as a list of the files that were converted.\n\nWhile most of your tests should be converted automatically, and you should be able to run them without any issues, there are some cases where you may need to manually convert some of your tests.\n\n---\n\nOf course, this particular chapter is only for those who are migrating from PHPUnit. Next, let's learn how you can contribute to the growth of Pest: [Community Guide](/docs/community-guide)\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## Migrations Indexes Foreign Key Constraints Toggling Foreign Key Constraints\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n#### Toggling Foreign Key Constraints\n\nYou may enable or disable foreign key constraints within your migrations by using the following methods:\n\n```php\nSchema::enableForeignKeyConstraints();\n\nSchema::disableForeignKeyConstraints();\n\nSchema::withoutForeignKeyConstraints(function () {\n    // Constraints disabled within this closure...\n});\n```\n\n> [!WARNING]\n> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations.\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## Packages Resources Migrations\nPackages | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/packages.md\n\n\n### Migrations\n\nIf your package contains [database migrations](/docs/{{version}}/migrations), you may use the `publishesMigrations` method to inform Laravel that the given directory or file contains migrations. When Laravel publishes the migrations, it will automatically update the timestamp within their filename to reflect the current date and time:\n\n```php\n/**\n * Bootstrap any package services.\n */\npublic function boot(): void\n{\n    $this->publishesMigrations([\n        __DIR__.'/../database/migrations' => database_path('migrations'),\n    ]);\n}\n```\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 Relationships\nEloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md\n\n# Eloquent: Relationships\n\n- [Introduction](#introduction)\n- [Defining Relationships](#defining-relationships)\n    - [One to One / Has One](#one-to-one)\n    - [One to Many / Has Many](#one-to-many)\n    - [One to Many (Inverse) / Belongs To](#one-to-many-inverse)\n    - [Has One of Many](#has-one-of-many)\n    - [Has One Through](#has-one-through)\n    - [Has Many Through](#has-many-through)\n- [Scoped Relationships](#scoped-relationships)\n- [Many to Many Relationships](#many-to-many)\n    - [Retrieving Intermediate Table Columns](#retrieving-intermediate-table-columns)\n    - [Filtering Queries via Intermediate Table Columns](#filtering-queries-via-intermediate-table-columns)\n    - [Ordering Queries via Intermediate Table Columns](#ordering-queries-via-intermediate-table-columns)\n    - [Defining Custom Intermediate Table Models](#defining-custom-intermediate-table-models)\n- [Polymorphic Relationships](#polymorphic-relationships)\n    - [One to One](#one-to-one-polymorphic-relations)\n    - [One to Many](#one-to-many-polymorphic-relations)\n    - [One of Many](#one-of-many-polymorphic-relations)\n    - [Many to Many](#many-to-many-polymorphic-relations)\n    - [Custom Polymorphic Types](#custom-polymorphic-types)\n- [Dynamic Relationships](#dynamic-relationships)\n- [Querying Relations](#querying-relations)\n    - [Relationship Methods vs. Dynamic Properties](#relationship-methods-vs-dynamic-properties)\n    - [Querying Relationship Existence](#querying-relationship-existence)\n    - [Querying Relationship Absence](#querying-relationship-absence)\n    - [Querying Morph To Relationships](#querying-morph-to-relationships)\n- [Aggregating Related Models](#aggregating-related-models)\n    - [Counting Related Models](#counting-related-models)\n    - [Other Aggregate Functions](#other-aggregate-functions)\n    - [Counting Related Models on Morph To Relationships](#counting-related-models-on-morph-to-relationships)\n- [Eager Loading](#eager-loading)\n    - [Constraining Eager Loads](#constraining-eager-loads)\n    - [Lazy Eager Loading](#lazy-eager-loading)\n    - [Automatic Eager Loading](#automatic-eager-loading)\n    - [Preventing Lazy Loading](#preventing-lazy-loading)\n- [Inserting and Updating Related Models](#inserting-and-updating-related-models)\n    - [The `save` Method](#the-save-method)\n    - [The `create` Method](#the-create-method)\n    - [Belongs To Relationships](#updating-belongs-to-relationships)\n    - [Many to Many Relationships](#updating-many-to-many-relationships)\n- [Touching Parent Timestamps](#touching-parent-timestamps)\n\n---\n\n## Migrations Indexes Foreign Key Constraints\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n### Foreign Key Constraints\n\nLaravel also provides support for creating foreign key constraints, which are used to force referential integrity at the database level. For example, let's define a `user_id` column on the `posts` table that references the `id` column on a `users` table:\n\n```php\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nSchema::table('posts', function (Blueprint $table) {\n    $table->unsignedBigInteger('user_id');\n\n    $table->foreign('user_id')->references('id')->on('users');\n});\n```\n\nSince this syntax is rather verbose, Laravel provides additional, terser methods that use conventions to provide a better developer experience. When using the `foreignId` method to create your column, the example above can be rewritten like so:\n\n```php\nSchema::table('posts', function (Blueprint $table) {\n    $table->foreignId('user_id')->constrained();\n});\n```\n\nThe `foreignId` method creates an `UNSIGNED BIGINT` equivalent column, while the `constrained` method will use conventions to determine the table and column being referenced. If your table name does not match Laravel's conventions, you may manually provide it to the `constrained` method. In addition, the name that should be assigned to the generated index may be specified as well:\n\n```php\nSchema::table('posts', function (Blueprint $table) {\n    $table->foreignId('user_id')->constrained(\n        table: 'users', indexName: 'posts_user_id'\n    );\n});\n```\n\nYou may also specify the desired action for the \"on delete\" and \"on update\" properties of the constraint:\n\n```php\n$table->foreignId('user_id')\n    ->constrained()\n    ->onUpdate('cascade')\n    ->onDelete('cascade');\n```\n\nAn alternative, expressive syntax is also provided for these actions:\n\n
\n\n| Method                        | Description                                       |\n| ----------------------------- | ------------------------------------------------- |\n| `$table->cascadeOnUpdate();`  | Updates should cascade.                           |\n| `$table->restrictOnUpdate();` | Updates should be restricted.                     |\n| `$table->nullOnUpdate();`     | Updates should set the foreign key value to null. |\n| `$table->noActionOnUpdate();` | No action on updates.                             |\n| `$table->cascadeOnDelete();`  | Deletes should cascade.                           |\n| `$table->restrictOnDelete();` | Deletes should be restricted.                     |\n| `$table->nullOnDelete();`     | Deletes should set the foreign key value to null. |\n| `$table->noActionOnDelete();` | Prevents deletes if child records exist.          |\n\n
\n\nAny additional [column modifiers](#column-modifiers) must be called before the `constrained` method:\n\n```php\n$table->foreignId('user_id')\n    ->nullable()\n    ->constrained();\n```\n\n---\n\n## Migrations Events\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n## Events\n\nFor convenience, each migration operation will dispatch an [event](/docs/{{version}}/events). All of the following events extend the base `Illuminate\\Database\\Events\\MigrationEvent` class:\n\n
\n\n| Class                                            | Description                                      |\n| ------------------------------------------------ | ------------------------------------------------ |\n| `Illuminate\\Database\\Events\\MigrationsStarted`   | A batch of migrations is about to be executed.   |\n| `Illuminate\\Database\\Events\\MigrationsEnded`     | A batch of migrations has finished executing.    |\n| `Illuminate\\Database\\Events\\MigrationStarted`    | A single migration is about to be executed.      |\n| `Illuminate\\Database\\Events\\MigrationEnded`      | A single migration has finished executing.       |\n| `Illuminate\\Database\\Events\\NoPendingMigrations` | A migration command found no pending migrations. |\n| `Illuminate\\Database\\Events\\SchemaDumped`        | A database schema dump has completed.            |\n| `Illuminate\\Database\\Events\\SchemaLoaded`        | An existing database schema dump has loaded.     |\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 Events\nEloquent | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent.md\n\n\n## Events\n\n> [!NOTE]\n> Want to broadcast your Eloquent events directly to your client-side application? Check out Laravel's [model event broadcasting](/docs/{{version}}/broadcasting#model-broadcasting).\n\nEloquent models dispatch several events, allowing you to hook into the following moments in a model's lifecycle: `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `trashed`, `forceDeleting`, `forceDeleted`, `restoring`, `restored`, and `replicating`.\n\nThe `retrieved` event will dispatch when an existing model is retrieved from the database. When a new model is saved for the first time, the `creating` and `created` events will dispatch. The `updating` / `updated` events will dispatch when an existing model is modified and the `save` method is called. The `saving` / `saved` events will dispatch when a model is created or updated - even if the model's attributes have not been changed. Event names ending with `-ing` are dispatched before any changes to the model are persisted, while events ending with `-ed` are dispatched after the changes to the model are persisted.\n\nTo start listening to model events, define a `$dispatchesEvents` property on your Eloquent model. This property maps various points of the Eloquent model's lifecycle to your own [event classes](/docs/{{version}}/events). Each model event class should expect to receive an instance of the affected model via its constructor:\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse App\\Events\\UserDeleted;\nuse App\\Events\\UserSaved;\nuse Illuminate\\Foundation\\Auth\\User as Authenticatable;\nuse Illuminate\\Notifications\\Notifiable;\n\nclass User extends Authenticatable\n{\n    use Notifiable;\n\n    /**\n     * The event map for the model.\n     *\n     * @var array<string, string>\n     */\n    protected $dispatchesEvents = [\n        'saved' => UserSaved::class,\n        'deleted' => UserDeleted::class,\n    ];\n}\n```\n\nAfter defining and mapping your Eloquent events, you may use [event listeners](/docs/{{version}}/events#defining-listeners) to handle the events.\n\n> [!WARNING]\n> When issuing a mass update or delete query via Eloquent, the `saved`, `updated`, `deleting`, and `deleted` model events will not be dispatched for the affected models. This is because the models are never actually retrieved when performing mass updates or deletes.\n\n---\n\n## Migrations Indexes Foreign Key Constraints Dropping Foreign Keys\nMigrations | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/migrations.md\n\n\n#### Dropping Foreign Keys\n\nTo drop a foreign key, you may use the `dropForeign` method, passing the name of the foreign key constraint to be deleted as an argument. Foreign key constraints use the same naming convention as indexes. In other words, the foreign key constraint name is based on the name of the table and the columns in the constraint, followed by a \"\\_foreign\" suffix:\n\n```php\n$table->dropForeign('posts_user_id_foreign');\n```\n\nAlternatively, you may pass an array containing the column name that holds the foreign key to the `dropForeign` method. The array will be converted to a foreign key constraint name using Laravel's constraint naming conventions:\n\n```php\n$table->dropForeign(['user_id']);\n```\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## 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 Relationships Introduction\nEloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md\n\n\n## Introduction\n\nDatabase tables are often related to one another. For example, a blog post may have many comments or an order could be related to the user who placed it. Eloquent makes managing and working with these relationships easy, and supports a variety of common relationships:\n\n
\n\n- [One To One](#one-to-one)\n- [One To Many](#one-to-many)\n- [Many To Many](#many-to-many)\n- [Has One Through](#has-one-through)\n- [Has Many Through](#has-many-through)\n- [One To One (Polymorphic)](#one-to-one-polymorphic-relations)\n- [One To Many (Polymorphic)](#one-to-many-polymorphic-relations)\n- [Many To Many (Polymorphic)](#many-to-many-polymorphic-relations)\n\n
\n\n_[Truncated]_"}]


step: Patch catalog service/model invariants from high QA findings

step: Add focused regression tests for lifecycle, variant options, tenancy, inventory consistency

status: pending

step: Rebuild seed data assertions and progress evidence

step: Run focused/full tests, Pint, fresh migration seed, then commit Phase 2 data layer

explanation: I’m handling the high-risk Phase 2 data-layer issues before moving to catalog UI/media work.


output: Plan updated


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


cmd: sed -n '1,220p' app/Models/ProductOption.php


cmd: sed -n '1,220p' app/Models/ProductOptionValue.php


cmd: sed -n '1,240p' database/migrations/2026_05_03_213904_create_product_media_table.php


cmd: sed -n '1,260p' database/migrations/2026_05_03_213859_create_product_variants_table.php


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

ProductMedia.php

type: read

aggregated_output:
<?php

namespace App\Models;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ProductMedia extends Model
{
    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
    use HasFactory;

    public const UPDATED_AT = null;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'type',
        'storage_key',
        'alt_text',
        'width',
        'height',
        'mime_type',
        'byte_size',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'image',
        'position' => 0,
        'status' => 'processing',
    ];

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => MediaType::class,
            'width' => 'integer',
            'height' => 'integer',
            'byte_size' => 'integer',
            'position' => 'integer',
            'status' => MediaStatus::class,
        ];
    }
}

status: completed


command: sed -n '1,220p' app/Models/ProductOption.php

aggregated_output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProductOption extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'name',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

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

    /**
     * @return HasMany<ProductOptionValue, $this>
     */
    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class)->orderBy('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}


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

namespace App\Models;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ProductMedia extends Model
{
    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
    use HasFactory;

    public const UPDATED_AT = null;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'type',
        'storage_key',
        'alt_text',
        'width',
        'height',
        'mime_type',
        'byte_size',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'image',
        'position' => 0,
        'status' => 'processing',
    ];

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => MediaType::class,
            'width' => 'integer',
            'height' => 'integer',
            'byte_size' => 'integer',
            'position' => 'integer',
            'status' => MediaStatus::class,
        ];
    }
}


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

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProductOption extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'name',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

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

    /**
     * @return HasMany<ProductOptionValue, $this>
     */
    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class)->orderBy('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}


command: sed -n '1,220p' app/Models/ProductOptionValue.php

aggregated_output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class ProductOptionValue extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionValueFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_option_id',
        'value',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    /**
     * @return BelongsTo<ProductOption, $this>
     */
    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}


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

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class ProductOptionValue extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionValueFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_option_id',
        'value',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    /**
     * @return BelongsTo<ProductOption, $this>
     */
    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}


command: sed -n '1,240p' database/migrations/2026_05_03_213904_create_product_media_table.php

aggregated_output:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_media', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->enum('type', ['image', 'video'])->default('image');
            $table->string('storage_key');
            $table->string('alt_text')->nullable();
            $table->unsignedInteger('width')->nullable();
            $table->unsignedInteger('height')->nullable();
            $table->string('mime_type')->nullable();
            $table->unsignedInteger('byte_size')->nullable();
            $table->unsignedInteger('position')->default(0);
            $table->enum('status', ['processing', 'ready', 'failed'])->default('processing');
            $table->timestamp('created_at')->nullable();

            $table->index('product_id');
            $table->index(['product_id', 'position']);
            $table->index('status');
        });
    }

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


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

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_media', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->enum('type', ['image', 'video'])->default('image');
            $table->string('storage_key');
            $table->string('alt_text')->nullable();
            $table->unsignedInteger('width')->nullable();
            $table->unsignedInteger('height')->nullable();
            $table->string('mime_type')->nullable();
            $table->unsignedInteger('byte_size')->nullable();
            $table->unsignedInteger('position')->default(0);
            $table->enum('status', ['processing', 'ready', 'failed'])->default('processing');
            $table->timestamp('created_at')->nullable();

            $table->index('product_id');
            $table->index(['product_id', 'position']);
            $table->index('status');
        });
    }

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


command: sed -n '1,260p' database/migrations/2026_05_03_213859_create_product_variants_table.php

aggregated_output:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_variants', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('sku')->nullable();
            $table->string('barcode')->nullable();
            $table->integer('price_amount')->default(0);
            $table->integer('compare_at_amount')->nullable();
            $table->string('currency', 3)->default('USD');
            $table->integer('weight_g')->nullable();
            $table->boolean('requires_shipping')->default(true);
            $table->boolean('is_default')->default(false);
            $table->unsignedInteger('position')->default(0);
            $table->enum('status', ['active', 'archived'])->default('active');
            $table->timestamps();

            $table->index('product_id');
            $table->index('sku');
            $table->index('barcode');
            $table->index(['product_id', 'position']);
            $table->index(['product_id', 'is_default']);
        });
    }

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


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

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_variants', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('sku')->nullable();
            $table->string('barcode')->nullable();
            $table->integer('price_amount')->default(0);
            $table->integer('compare_at_amount')->nullable();
            $table->string('currency', 3)->default('USD');
            $table->integer('weight_g')->nullable();
            $table->boolean('requires_shipping')->default(true);
            $table->boolean('is_default')->default(false);
            $table->unsignedInteger('position')->default(0);
            $table->enum('status', ['active', 'archived'])->default('active');
            $table->timestamps();

            $table->index('product_id');
            $table->index('sku');
            $table->index('barcode');
            $table->index(['product_id', 'position']);
            $table->index(['product_id', 'is_default']);
        });
    }

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


message: For tenant isolation, I’m not adding duplicate `store_id` columns to every child table unless the spec forces that. The safer local pattern is a parent-store global scope on child models, plus a save-time invariant for inventory rows where `store_id` is already denormalized.


text: For tenant isolation, I’m not adding duplicate `store_id` columns to every child table unless the spec forces that. The safer local pattern is a parent-store global scope on child models, plus a save-time invariant for inventory rows where `store_id` is already denormalized.


cmd: rg -n "ProductStatus|ProductService|ProductVariant|ProductOption|ProductMedia|variant_option|status" specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/07-SEEDERS-AND-TEST-DATA.md


cmd: sed -n '1450,1535p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '1,260p' specs/07-SEEDERS-AND-TEST-DATA.md


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


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

aggregated_output:
# 01 - Database Schema Specification

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

---

## Conventions

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

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

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

**Indexes:**

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

---

### stores

Individual storefront belonging to an organization.

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

**Indexes:**

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

---

### store_domains

Custom domains attached to a store.

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

**Indexes:**

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

---

### users

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

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

**Indexes:**

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

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` fi…1344 chars truncated…ct record, scoped to a store.

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

**Indexes:**

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

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

---

### product_options

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

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

**Indexes:**

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

---

### product_option_values

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

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

**Indexes:**

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

---

### product_variants

Purchasable SKU-level variant of a product.

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

**Indexes:**

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

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

---

### variant_option_values

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

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


command: sed -n '1,260p' specs/07-SEEDERS-AND-TEST-DATA.md

aggregated_output:
# 07 - Seeders and Test Data Specification

> Complete seeder and factory specification for populating the platform with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.

---

## 1. DatabaseSeeder Orchestration

The main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.

**Execution order:**

| Step | Seeder Class | Depends On | Purpose |
|------|-------------|------------|---------|
| 1 | OrganizationSeeder | (none) | Creates billing organizations |
| 2 | StoreSeeder | Organizations | Creates stores under organizations |
| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |
| 4 | UserSeeder | (none) | Creates admin/staff user accounts |
| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |
| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |
| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |
| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |
| 9 | CollectionSeeder | Stores | Creates product collections |
| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |
| 11 | DiscountSeeder | Stores | Creates discount codes |
| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |
| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |
| 14 | ThemeSeeder | Stores | Creates themes with settings |
| 15 | PageSeeder | Stores | Creates content pages |
| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |
| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |
| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |

**Dependency diagram (Mermaid):**

```mermaid
graph TD
    A[OrganizationSeeder] --> B[StoreSeeder]
    B --> C[StoreDomainSeeder]
    B --> F[StoreSettingsSeeder]
    B --> G[TaxSettingsSeeder]
    B --> H[ShippingSeeder]
    B --> I[CollectionSeeder]
    D[UserSeeder] --> E[StoreUserSeeder]
    B --> E
    B --> J[ProductSeeder]
    I --> J
    B --> K[DiscountSeeder]
    B --> L[CustomerSeeder]
    J --> M[OrderSeeder]
    L --> M
    K --> M
    B --> N[ThemeSeeder]
    B --> O[PageSeeder]
    I --> P[NavigationSeeder]
    O --> P
    B --> Q[AnalyticsSeeder]
    L --> Q
    B --> R[SearchSettingsSeeder]
```

**Running seeders:**
- All seeders: `php artisan db:seed`
- Single seeder: `php artisan db:seed --class=ProductSeeder`
- Fresh database with seed data: `php artisan migrate:fresh --seed`

---

## 2. Factory Definitions

Each factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.

---

### 2.1 OrganizationFactory

**Model:** Organization

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| name | Faker company name |
| billing_email | Faker company email |

**States:** None.

---

### 2.2 StoreFactory

**Model:** Store

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| organization_id | Creates a new Organization via its factory |
| name | Faker company name + " Store" suffix |
| handle | Faker unique slug (2 words) |
| status | "active" |
| default_currency | "EUR" |
| default_locale | "en" |
| timezone | "Europe/Berlin" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `suspended` | status = "suspended" |

---

### 2.3 StoreDomainFactory

**Model:** StoreDomain

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| hostname | Faker unique domain name |
| type | "storefront" |
| is_primary | 1 |
| tls_mode | "managed" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `admin` | type = "admin" |
| `api` | type = "api" |
| `secondary` | is_primary = 0 |

---

### 2.4 UserFactory

**Model:** User

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| email | Faker unique safe email |
| password_hash | bcrypt of "password" |
| name | Faker full name |
| status | "active" |
| last_login_at | Random date within the last 30 days |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `disabled` | status = "disabled" |

---

### 2.5 ProductFactory

**Model:** Product

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| title | Faker 3 random words joined |
| handle | URL-slug of the title |
| status | "active" |
| description_html | Faker 2 paragraphs wrapped in `<p>` tags |
| vendor | Faker company name |
| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |
| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |
| published_at | Current timestamp |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `draft` | status = "draft", published_at = null |
| `archived` | status = "archived" |

**Relationship hooks:**

| Hook Name | Behavior |
|-----------|----------|
| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |
| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |

---

### 2.6 ProductVariantFactory

**Model:** ProductVariant

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| product_id | Creates a new Product via its factory |
| sku | Faker unique, pattern: "SKU-####-???" (digits and letters) |
| barcode | Faker EAN-13 |
| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |
| compare_at_amount | null |
| currency | "EUR" |
| weight_g | Random integer 100-5000 |
| requires_shipping | 1 |
| is_default | 0 |
| position | 0 |
| status | "active" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |
| `digital` | requires_shipping = 0, weight_g = 0 |
| `default` | is_default = 1 |
| `archived` | status = "archived" |

---

### 2.7 InventoryItemFactory

**Model:** InventoryItem

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| variant_id | Creates a new ProductVariant via its factory |
| quantity_on_hand | Random integer 0-100 |
| quantity_reserved | 0 |
| policy | "deny" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `outOfStock` | quantity_on_hand = 0 |
| `continuePolicy` | policy = "continue" |
| `lowStock` | quantity_on_hand = random 1-3 |

---

### 2.8 CollectionFactory

**Model:** Collection

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| title | Faker 2 random words joined |
| handle | URL-slug of the title |
| description_html | Faker sentence wrapped in `<p>` tags |


command: sed -n '1450,1535p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
      "properties": { ... }
    }
  ]
}
```

**Event fields:**

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |
| `session_id` | string | Yes | Client session identifier |
| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |
| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |
| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |

**Deduplication:** Events are deduplicated by `client_event_id` within the store scope. If a `client_event_id` has already been recorded for the current store (enforced by the unique index on `store_id, client_event_id`), the duplicate event is silently dropped.

**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.

### 14.2 Aggregation

**Job:** `App\Jobs\AggregateAnalytics`

- Schedule: daily at 01:00 UTC
- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.

**Aggregated metrics:**

| Metric | Calculation |
|---|---|
| `orders_count` | Count of `checkout_completed` events for the day |
| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
| `visits_count` | Count of distinct `session_id` values with `page_view` events |
| `add_to_cart_count` | Count of `add_to_cart` events |
| `checkout_started_count` | Count of `checkout_started` events |

---

## 15. Media Processing

### 15.1 Upload Flow

```mermaid
flowchart TD
    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
    B --> C[Create product_media record with status = processing]
    C --> D[Dispatch ProcessMediaUpload job to queue]
    D --> E{Processing succeeds?}
    E -->|Yes| F[Store resized versions, set status = ready]
    E -->|No| G[Set status = failed, log error, keep original]
```

### 15.2 Processing

**Job:** `App\Jobs\ProcessMediaUpload`

**Resize targets:**

| Key | Max Dimensions | Use Case |
|---|---|---|
| `thumbnail` | 150x150 px | Admin grid, small previews |
| `small` | 300x300 px | Product cards, listings |
| `medium` | 600x600 px | Product detail page |
| `large` | 1200x1200 px | Full-size product image, zoom |

**Processing rules:**
- Maintain aspect ratio (contain within target dimensions, no cropping).
- Generate WebP version alongside the original format if the server supports it.
- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
- Maximum 3 retry attempts before permanent failure.

### 15.3 Cleanup

When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).

---

## 16. Search (SQLite FTS5)

### 16.1 Index Setup

Create an FTS5 virtual table named `products_fts` with these columns:


command: rg -n "ProductStatus|ProductService|ProductVariant|ProductOption|ProductMedia|variant_option|status" specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/07-SEEDERS-AND-TEST-DATA.md

type: search

cmd: rg -n 'ProductStatus|ProductService|ProductVariant|ProductOption|ProductMedia|variant_option|status' specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/07-SEEDERS-AND-TEST-DATA.md

query: ProductStatus|ProductService|ProductVariant|ProductOption|ProductMedia|variant_option|status

aggregated_output:
specs/01-DATABASE-SCHEMA.md:53:| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
specs/01-DATABASE-SCHEMA.md:64:- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status
specs/01-DATABASE-SCHEMA.md:100:| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:113:- `idx_users_status` on (`status`) - Filtering users by active/disabled
specs/01-DATABASE-SCHEMA.md:165:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:178:- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
specs/01-DATABASE-SCHEMA.md:240:| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:256:### variant_option_values
specs/01-DATABASE-SCHEMA.md:269:- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value
specs/01-DATABASE-SCHEMA.md:309:| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:317:- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status
specs/01-DATABASE-SCHEMA.md:356:| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
specs/01-DATABASE-SCHEMA.md:363:- `idx_product_media_status` on (`status`) - Finding media by processing status
specs/01-DATABASE-SCHEMA.md:379:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
specs/01-DATABASE-SCHEMA.md:387:- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store
specs/01-DATABASE-SCHEMA.md:434:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
specs/01-DATABASE-SCHEMA.md:443:- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status
specs/01-DATABASE-SCHEMA.md:539:| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
specs/01-DATABASE-SCHEMA.md:547:- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)
specs/01-DATABASE-SCHEMA.md:587:| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
specs/01-DATABASE-SCHEMA.md:605:- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status
specs/01-DATABASE-SCHEMA.md:689:| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:697:- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
specs/01-DATABASE-SCHEMA.md:762:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |
specs/01-DATABASE-SCHEMA.md:763:| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |
specs/01-DATABASE-SCHEMA.md:764:| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
specs/01-DATABASE-SCHEMA.md:783:- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status
specs/01-DATABASE-SCHEMA.md:784:- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status
specs/01-DATABASE-SCHEMA.md:785:- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status
specs/01-DATABASE-SCHEMA.md:829:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |
specs/01-DATABASE-SCHEMA.md:840:- `idx_payments_status` on (`status`) - Filtering payments by status
specs/01-DATABASE-SCHEMA.md:857:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |
specs/01-DATABASE-SCHEMA.md:865:- `idx_refunds_status` on (`status`) - Filtering refunds by status
specs/01-DATABASE-SCHEMA.md:877:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |
specs/01-DATABASE-SCHEMA.md:887:- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status
specs/01-DATABASE-SCHEMA.md:979:| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:984:- `idx_apps_status` on (`status`) - Filtering apps by status
specs/01-DATABASE-SCHEMA.md:998:| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |
specs/01-DATABASE-SCHEMA.md:1060:| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:1080:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |
specs/01-DATABASE-SCHEMA.md:1089:- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry
specs/01-DATABASE-SCHEMA.md:1224:        TEXT status
specs/01-DATABASE-SCHEMA.…16150 chars truncated… days)`.
specs/05-BUSINESS-LOGIC.md:1051:2. Update `orders.financial_status` to `voided`.
specs/05-BUSINESS-LOGIC.md:1052:3. Update `orders.status` to `cancelled`.
specs/05-BUSINESS-LOGIC.md:1053:4. Update `payments.status` to `failed`.
specs/05-BUSINESS-LOGIC.md:1073:| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
specs/05-BUSINESS-LOGIC.md:1088:Three independent status dimensions tracked on each order:
specs/05-BUSINESS-LOGIC.md:1090:**Order status** (`orders.status`):
specs/05-BUSINESS-LOGIC.md:1100:**Financial status** (`orders.financial_status`):
specs/05-BUSINESS-LOGIC.md:1111:**Fulfillment status** (`orders.fulfillment_status`):
specs/05-BUSINESS-LOGIC.md:1148:    4. CREATE Refund record with status = 'pending'
specs/05-BUSINESS-LOGIC.md:1151:       UPDATE refund status = 'processed'
specs/05-BUSINESS-LOGIC.md:1155:           SET order.financial_status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1156:           SET order.status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1158:           SET order.financial_status = 'partially_refunded'
specs/05-BUSINESS-LOGIC.md:1173:**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.
specs/05-BUSINESS-LOGIC.md:1175:| `financial_status` | Fulfillment Allowed? | Reason |
specs/05-BUSINESS-LOGIC.md:1196:    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
specs/05-BUSINESS-LOGIC.md:1204:    3. CREATE Fulfillment record with status = 'pending' and tracking data
specs/05-BUSINESS-LOGIC.md:1208:    5. Determine new fulfillment status:
specs/05-BUSINESS-LOGIC.md:1215:           SET order.fulfillment_status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1216:           SET order.status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1218:           SET order.fulfillment_status = 'partial'
specs/05-BUSINESS-LOGIC.md:1225:Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.
specs/05-BUSINESS-LOGIC.md:1242:| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
specs/05-BUSINESS-LOGIC.md:1243:| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
specs/05-BUSINESS-LOGIC.md:1270:   - `status = 'delivered'` (skip pending and shipped)
specs/05-BUSINESS-LOGIC.md:1273:   - Set `order.fulfillment_status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1274:   - Set `order.status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1283:**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
specs/05-BUSINESS-LOGIC.md:1379:    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md:1497:    B --> C[Create product_media record with status = processing]
specs/05-BUSINESS-LOGIC.md:1500:    E -->|Yes| F[Store resized versions, set status = ready]
specs/05-BUSINESS-LOGIC.md:1501:    E -->|No| G[Set status = failed, log error, keep original]
specs/05-BUSINESS-LOGIC.md:1521:- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
specs/05-BUSINESS-LOGIC.md:1522:- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
specs/05-BUSINESS-LOGIC.md:1578:    5. Exclude products with status != 'active' or published_at IS NULL
specs/05-BUSINESS-LOGIC.md:1600:| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |
specs/05-BUSINESS-LOGIC.md:1604:| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
specs/05-BUSINESS-LOGIC.md:1605:| `FulfillmentCreated` | Update order fulfillment status |
specs/05-BUSINESS-LOGIC.md:1638:| ProductService | `App\Services` | Product CRUD, status transitions |
specs/05-BUSINESS-LOGIC.md:1652:| FulfillmentService | `App\Services` | Fulfillment creation and status management |
specs/05-BUSINESS-LOGIC.md:1676:| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |
specs/05-BUSINESS-LOGIC.md:1705:| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |
specs/05-BUSINESS-LOGIC.md:1707:| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |