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

<?php

namespace App\Enums;

enum InventoryPolicy: string
{
    case Deny = 'deny';
    case Continue = 'continue';
}


 M database/seeders/DatabaseSeeder.php
?? app/Enums/CollectionStatus.php
?? app/Enums/CollectionType.php
?? app/Enums/InventoryPolicy.php
?? app/Enums/MediaStatus.php
?? app/Enums/MediaType.php
?? app/Enums/ProductStatus.php
?? app/Enums/VariantStatus.php
?? app/Events/
?? app/Exceptions/
?? app/Jobs/
?? app/Models/Collection.php
?? app/Models/InventoryItem.php
?? app/Models/Product.php
?? app/Models/ProductMedia.php
?? app/Models/ProductOption.php
?? app/Models/ProductOptionValue.php
?? app/Models/ProductVariant.php
?? app/Services/
?? app/Support/
?? database/factories/CollectionFactory.php
?? database/factories/InventoryItemFactory.php
?? database/factories/ProductFactory.php
?? database/factories/ProductMediaFactory.php
?? database/factories/ProductOptionFactory.php
?? database/factories/ProductOptionValueFactory.php
?? database/factories/ProductVariantFactory.php
?? database/migrations/2026_05_03_213856_create_products_table.php
?? database/migrations/2026_05_03_213857_create_product_options_table.php
?? database/migrations/2026_05_03_213858_create_product_option_values_table.php
?? database/migrations/2026_05_03_213859_create_product_variants_table.php
?? database/migrations/2026_05_03_213900_create_variant_option_values_table.php
?? database/migrations/2026_05_03_213901_create_inventory_items_table.php
?? database/migrations/2026_05_03_213902_create_collections_table.php
?? database/migrations/2026_05_03_213903_create_collection_products_table.php
?? database/migrations/2026_05_03_213904_create_product_media_table.php
?? database/seeders/CollectionSeeder.php
?? database/seeders/InventoryItemSeeder.php
?? database/seeders/ProductMediaSeeder.php
?? database/seeders/ProductOptionSeeder.php
?? database/seeders/ProductOptionValueSeeder.php
?? database/seeders/ProductSeeder.php
?? database/seeders/ProductVariantSeeder.php
?? tests/Feature/Catalog/


Output:
Chunk ID: 6f054f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 471
Output:
 M database/seeders/DatabaseSeeder.php
?? app/Enums/CollectionStatus.php
?? app/Enums/CollectionType.php
?? app/Enums/InventoryPolicy.php
?? app/Enums/MediaStatus.php
?? app/Enums/MediaType.php
?? app/Enums/ProductStatus.php
?? app/Enums/VariantStatus.php
?? app/Events/
?? app/Exceptions/
?? app/Jobs/
?? app/Models/Collection.php
?? app/Models/InventoryItem.php
?? app/Models/Product.php
?? app/Models/ProductMedia.php
?? app/Models/ProductOption.php
?? app/Models/ProductOptionValue.php
?? app/Models/ProductVariant.php
?? app/Services/
?? app/Support/
?? database/factories/CollectionFactory.php
?? database/factories/InventoryItemFactory.php
?? database/factories/ProductFactory.php
?? database/factories/ProductMediaFactory.php
?? database/factories/ProductOptionFactory.php
?? database/factories/ProductOptionValueFactory.php
?? database/factories/ProductVariantFactory.php
?? database/migrations/2026_05_03_213856_create_products_table.php
?? database/migrations/2026_05_03_213857_create_product_options_table.php
?? database/migrations/2026_05_03_213858_create_product_option_values_table.php
?? database/migrations/2026_05_03_213859_create_product_variants_table.php
?? database/migrations/2026_05_03_213900_create_variant_option_values_table.php
?? database/migrations/2026_05_03_213901_create_inventory_items_table.php
?? database/migrations/2026_05_03_213902_create_collections_table.php
?? database/migrations/2026_05_03_213903_create_collection_products_table.php
?? database/migrations/2026_05_03_213904_create_product_media_table.php
?? database/seeders/CollectionSeeder.php
?? database/seeders/InventoryItemSeeder.php
?? database/seeders/ProductMediaSeeder.php
?? database/seeders/ProductOptionSeeder.php
?? database/seeders/ProductOptionValueSeeder.php
?? database/seeders/ProductSeeder.php
?? database/seeders/ProductVariantSeeder.php
?? tests/Feature/Catalog/


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


Output:
Chunk ID: 788f76
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 30
Output:
<?php

namespace App\Enums;

enum InventoryPolicy: string
{
    case Deny = 'deny';
    case Continue = 'continue';
}


Output:
Chunk ID: b55eb2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 824
Output:
<?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.');
        }
    }
}


Output:
Chunk ID: f0be40
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1824
Output:
# 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.


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


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


sed -n '1,260p' database/migrations/2026_05_03_213901_create_inventory_items_table.php


/bin/zsh
-lc
sed -n '1,260p' app/Models/Product.php

<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
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\HasMany;

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

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
        'tags' => '[]',
    ];

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

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

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

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

    /**
     * @return BelongsToMany<Collection, $this>
     */
    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    public function isPublished(): bool
    {
        return $this->status === ProductStatus::Active && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }
}


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

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
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\HasMany;

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

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'draft',
        'tags' => '[]',
    ];

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

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

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

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

    /**
     * @return BelongsToMany<Collection, $this>
     */
    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position')
            ->orderByPivot('position');
    }

    public function isPublished(): bool
    {
        return $this->status === ProductStatus::Active && $this->published_at !== null;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }
}


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

<?php

namespace App\Models\Scopes;

use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class StoreScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('current_store')) {
            return;
        }

        $store = app('current_store');

        if (! $store instanceof Store) {
            return;
        }

        $builder->where($model->qualifyColumn('store_id'), $store->getKey());
    }
}


sed -n '1,260p' database/migrations/2026_05_03_213901_create_inventory_items_table.php

<?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('inventory_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete();
            $table->integer('quantity_on_hand')->default(0);
            $table->integer('quantity_reserved')->default(0);
            $table->enum('policy', ['deny', 'continue'])->default('deny');

            $table->index('store_id');
        });
    }

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


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

<?php

namespace App\Models\Concerns;

use App\Models\Scopes\StoreScope;
use App\Models\Store;

trait BelongsToStore
{
    public static function bootBelongsToStore(): void
    {
        static::addGlobalScope(new StoreScope);

        static::creating(function (object $model): void {
            if ($model->store_id || ! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if ($store instanceof Store) {
                $model->store_id = $store->getKey();
            }
        });
    }
}


Output:
Chunk ID: 8f1ee3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 143
Output:
<?php

namespace App\Models\Concerns;

use App\Models\Scopes\StoreScope;
use App\Models\Store;

trait BelongsToStore
{
    public static function bootBelongsToStore(): void
    {
        static::addGlobalScope(new StoreScope);

        static::creating(function (object $model): void {
            if ($model->store_id || ! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if ($store instanceof Store) {
                $model->store_id = $store->getKey();
            }
        });
    }
}


Output:
Chunk ID: 28d8a8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 141
Output:
<?php

namespace App\Models\Scopes;

use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class StoreScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('current_store')) {
            return;
        }

        $store = app('current_store');

        if (! $store instanceof Store) {
            return;
        }

        $builder->where($model->qualifyColumn('store_id'), $store->getKey());
    }
}


Output:
Chunk ID: cef683
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 234
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('inventory_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete();
            $table->integer('quantity_on_hand')->default(0);
            $table->integer('quantity_reserved')->default(0);
            $table->enum('policy', ['deny', 'continue'])->default('deny');

            $table->index('store_id');
        });
    }

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


I found one tenancy edge case: the variant-created event reads the parent product through the normal scoped relationship, so it can miss the product if `current_store` is bound to a different store. I’m changing that lookup to bypass product scopes and using the inventory policy enum consistently.


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

+use App\Enums\InventoryPolicy;
 use App\Enums\VariantStatus;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@
-            $storeId = $variant->product()->value('store_id');
+            $storeId = Product::withoutGlobalScopes()
+                ->whereKey($variant->product_id)
+                ->value('store_id');
@@
-                'policy' => 'deny',
+                'policy' => InventoryPolicy::Deny,
             ]);
         });
     }
*** End Patch


Success. Updated the following files:
M app/Models/ProductVariant.php

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

+use App\Enums\InventoryPolicy;
 use App\Enums\VariantStatus;
@@ -52,3 +53,5 @@

-            $storeId = $variant->product()->value('store_id');
+            $storeId = Product::withoutGlobalScopes()
+                ->whereKey($variant->product_id)
+                ->value('store_id');

@@ -63,3 +66,3 @@
                 'quantity_reserved' => 0,
-                'policy' => 'deny',
+                'policy' => InventoryPolicy::Deny,
             ]);


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


sed -n '1,340p' database/seeders/ProductSeeder.php


sed -n '1,220p' database/seeders/InventoryItemSeeder.php


sed -n '1,260p' database/seeders/DatabaseSeeder.php


sed -n '1,340p' database/seeders/ProductSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Collection as ProductCollection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
        $collections = ProductCollection::query()
            ->where('store_id', $store->getKey())
            ->get()
            ->keyBy('handle');

        foreach ($this->products() as $index => $data) {
            $product = Product::query()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'handle' => $data['handle'],
                ],
                [
                    ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']),
                    'store_id' => $store->getKey(),
                ],
            );

            $this->createOptions($product, $data['options'] ?? []);
            $this->createVariants($store, $product, $data['variants']);
            $this->createMedia($product);

            $collectionIds = collect($data['collections'] ?? [])
                ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey())
                ->filter()
                ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]])
                ->all();

            $product->collections()->syncWithoutDetaching($collectionIds);
        }
    }

    /**
     * @param  array<string, array<int, string>>  $options
     */
    private function createOptions(Product $product, array $options): void
    {
        foreach (array_values($options) as $optionPosition => $valuesByName) {
            $option = ProductOption::query()->updateOrCreate(
                [
                    'product_id' => $product->getKey(),
                    'position' => $optionPosition,
                ],
                [
                    'name' => array_key_first($valuesByName),
                ],
            );

            foreach (array_values($valuesByName[array_key_first($valuesByName)]) as $valuePosition => $value) {
                ProductOptionValue::query()->updateOrCreate(
                    [
                        'product_option_id' => $option->getKey(),
                        'position' => $valuePosition,
                    ],
                    ['value' => $value],
                );
            }
        }
    }

    /**
     * @param  array<int, array<string, mixed>>  $variants
     */
    private function createVariants(Store $store, Product $product, array $variants): void
    {
        foreach ($variants as $position => $variantData) {
            $variant = ProductVariant::query()->updateOrCreate(
                [
                    'product_id' => $product->getKey(),
                    'position' => $position,
                ],
                [
                    'sku' => $variantData['sku'] ?? null,
                    'barcode' => $variantData['barcode'] ?? null,
                    'price_amount' => $variantData['price_amount'],
                    'compare_at_amount' => $variantData['compare_at_amount'] ?? null,
                    'currency' => $store->default_currency,
                    'weight_g' => $variantData['weight_g'] ?? 250,
                    'requires_shipping' => $variantData['requires_shipping'] ?? true,
                    'is_default' => $position === 0,
                    'status' => $variantData['status'] ?? 'active',
                ],
            );

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

            if (isset($variantData['options'])) {
                $this->syncVariantOptions($variant, $variantData['options']);
            }
        }
    }

    /**
     * @param  array<string, string>  $selectedOptions
     */
    private function syncVariantOptions(ProductVariant $variant, array $selectedOptions): void
    {
        $valueIds = ProductOptionValue::query()
            ->whereHas('option', function ($query) use ($variant, $selectedOptions): void {
                $query
                    ->where('product_id', $variant->product_id)
                    ->whereIn('name', array_keys($selectedOptions));
            })
            ->get()
            ->filter(fn (ProductOptionValue $value): bool => $selectedOptions[$value->option->name] === $value->value)
            ->pluck('id')
            ->all();

        $variant->optionValues()->sync($valueIds);
    }

    private function createMedia(Product $product): void
    {
        ProductMedia::query()->updateOrCreate(
            [
                'product_id' => $product->getKey(),
                'position' => 0,
            ],
            [
                'type' => 'image',
                'storage_key' => 'products/'.$product->handle.'.jpg',
                'alt_text' => $product->title,
                'width' => 1200,
                'height' => 1200,
                'mime_type' => 'image/jpeg',
                'byte_size' => 180000,
                'status' => 'ready',
            ],
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function products(): array
    {
        $base = [
            [
                'title' => 'Classic Cotton T-Shirt',
                'handle' => 'classic-cotton-t-shirt',
                'product_type' => 'Shirts',
                'tags' => ['new', 'popular'],
                'collections' => ['t-shirts', 'new-arrivals'],
                'options' => [
                    ['Size' => ['S', 'M', 'L', 'XL']],
                    ['Color' => ['Black', 'White', 'Navy']],
                ],
                'variants' => [
                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Black']],
                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'White']],
                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Navy']],
                ],
            ],
            [
                'title' => 'Premium Slim Fit Jeans',
                'handle' => 'premium-slim-fit-jeans',
                'product_type' => 'Pants',
                'tags' => ['sale', 'popular'],
                'collections' => ['new-arrivals', 'sale'],
                'variants' => [
                    ['sku' => 'JNS-SLM-32', 'price_amount' => 7999, 'compare_at_amount' => 9999, 'quantity_on_hand' => 35],
                ],
            ],
        ];

        for ($i = 3; $i <= 20; $i++) {
            $base[] = [
                'title' => match ($i) {
                    15 => 'Draft Linen Overshirt',
                    17 => 'Sold Out Canvas Tote',
                    18 => 'Backorder Wool Scarf',
                    default => "Acme Catalog Product {$i}",
                },
                'handle' => match ($i) {
                    15 => 'draft-linen-overshirt',
                    17 => 'sold-out-canvas-tote',
                    18 => 'backorder-wool-scarf',
                    default => "acme-catalog-product-{$i}",
                },
                'status' => $i === 15 ? 'draft' : 'active',
                'published_at' => $i === 15 ? null : now(),
                'product_type' => $i % 2 === 0 ? 'Accessories' : 'Shirts',
                'tags' => $i % 2 === 0 ? ['new'] : ['trending'],
                'collections' => $i % 2 === 0 ? ['accessories'] : ['new-arrivals'],
                'variants' => [[
                    'sku' => "ACME-{$i}",
                    'price_amount' => $i === 17 ? 1999 : 2999 + ($i * 100),
                    'quantity_on_hand' => in_array($i, [17, 18], true) ? 0 : 20 + $i,
                    'policy' => $i === 18 ? 'continue' : 'deny',
                ]],
            ];
        }

        return array_map(function (array $product): array {
            return [
                'status' => 'active',
                'description_html' => '
Built for the Acme Fashion demo catalog.
',
                'vendor' => 'Acme',
                'published_at' => now(),
                ...$product,
            ];
        }, $base);
    }
}


Output:
Chunk ID: 01f725
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2227
Output:
<?php

namespace Database\Seeders;

use App\Models\Collection as ProductCollection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
        $collections = ProductCollection::query()
            ->where('store_id', $store->getKey())
            ->get()
            ->keyBy('handle');

        foreach ($this->products() as $index => $data) {
            $product = Product::query()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'handle' => $data['handle'],
                ],
                [
                    ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']),
                    'store_id' => $store->getKey(),
                ],
            );

            $this->createOptions($product, $data['options'] ?? []);
            $this->createVariants($store, $product, $data['variants']);
            $this->createMedia($product);

            $collectionIds = collect($data['collections'] ?? [])
                ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey())
                ->filter()
                ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]])
                ->all();

            $product->collections()->syncWithoutDetaching($collectionIds);
        }
    }

    /**
     * @param  array<string, array<int, string>>  $options
     */
    private function createOptions(Product $product, array $options): void
    {
        foreach (array_values($options) as $optionPosition => $valuesByName) {
            $option = ProductOption::query()->updateOrCreate(
                [
                    'product_id' => $product->getKey(),
                    'position' => $optionPosition,
                ],
                [
                    'name' => array_key_first($valuesByName),
                ],
            );

            foreach (array_values($valuesByName[array_key_first($valuesByName)]) as $valuePosition => $value) {
                ProductOptionValue::query()->updateOrCreate(
                    [
                        'product_option_id' => $option->getKey(),
                        'position' => $valuePosition,
                    ],
                    ['value' => $value],
                );
            }
        }
    }

    /**
     * @param  array<int, array<string, mixed>>  $variants
     */
    private function createVariants(Store $store, Product $product, array $variants): void
    {
        foreach ($variants as $position => $variantData) {
            $variant = ProductVariant::query()->updateOrCreate(
                [
                    'product_id' => $product->getKey(),
                    'position' => $position,
                ],
                [
                    'sku' => $variantData['sku'] ?? null,
                    'barcode' => $variantData['barcode'] ?? null,
                    'price_amount' => $variantData['price_amount'],
                    'compare_at_amount' => $variantData['compare_at_amount'] ?? null,
                    'currency' => $store->default_currency,
                    'weight_g' => $variantData['weight_g'] ?? 250,
                    'requires_shipping' => $variantData['requires_shipping'] ?? true,
                    'is_default' => $position === 0,
                    'status' => $variantData['status'] ?? 'active',
                ],
            );

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

            if (isset($variantData['options'])) {
                $this->syncVariantOptions($variant, $variantData['options']);
            }
        }
    }

    /**
     * @param  array<string, string>  $selectedOptions
     */
    private function syncVariantOptions(ProductVariant $variant, array $selectedOptions): void
    {
        $valueIds = ProductOptionValue::query()
            ->whereHas('option', function ($query) use ($variant, $selectedOptions): void {
                $query
                    ->where('product_id', $variant->product_id)
                    ->whereIn('name', array_keys($selectedOptions));
            })
            ->get()
            ->filter(fn (ProductOptionValue $value): bool => $selectedOptions[$value->option->name] === $value->value)
            ->pluck('id')
            ->all();

        $variant->optionValues()->sync($valueIds);
    }

    private function createMedia(Product $product): void
    {
        ProductMedia::query()->updateOrCreate(
            [
                'product_id' => $product->getKey(),
                'position' => 0,
            ],
            [
                'type' => 'image',
                'storage_key' => 'products/'.$product->handle.'.jpg',
                'alt_text' => $product->title,
                'width' => 1200,
                'height' => 1200,
                'mime_type' => 'image/jpeg',
                'byte_size' => 180000,
                'status' => 'ready',
            ],
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function products(): array
    {
        $base = [
            [
                'title' => 'Classic Cotton T-Shirt',
                'handle' => 'classic-cotton-t-shirt',
                'product_type' => 'Shirts',
                'tags' => ['new', 'popular'],
                'collections' => ['t-shirts', 'new-arrivals'],
                'options' => [
                    ['Size' => ['S', 'M', 'L', 'XL']],
                    ['Color' => ['Black', 'White', 'Navy']],
                ],
                'variants' => [
                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Black']],
                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'White']],
                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Navy']],
                ],
            ],
            [
                'title' => 'Premium Slim Fit Jeans',
                'handle' => 'premium-slim-fit-jeans',
                'product_type' => 'Pants',
                'tags' => ['sale', 'popular'],
                'collections' => ['new-arrivals', 'sale'],
                'variants' => [
                    ['sku' => 'JNS-SLM-32', 'price_amount' => 7999, 'compare_at_amount' => 9999, 'quantity_on_hand' => 35],
                ],
            ],
        ];

        for ($i = 3; $i <= 20; $i++) {
            $base[] = [
                'title' => match ($i) {
                    15 => 'Draft Linen Overshirt',
                    17 => 'Sold Out Canvas Tote',
                    18 => 'Backorder Wool Scarf',
                    default => "Acme Catalog Product {$i}",
                },
                'handle' => match ($i) {
                    15 => 'draft-linen-overshirt',
                    17 => 'sold-out-canvas-tote',
                    18 => 'backorder-wool-scarf',
                    default => "acme-catalog-product-{$i}",
                },
                'status' => $i === 15 ? 'draft' : 'active',
                'published_at' => $i === 15 ? null : now(),
                'product_type' => $i % 2 === 0 ? 'Accessories' : 'Shirts',
                'tags' => $i % 2 === 0 ? ['new'] : ['trending'],
                'collections' => $i % 2 === 0 ? ['accessories'] : ['new-arrivals'],
                'variants' => [[
                    'sku' => "ACME-{$i}",
                    'price_amount' => $i === 17 ? 1999 : 2999 + ($i * 100),
                    'quantity_on_hand' => in_array($i, [17, 18], true) ? 0 : 20 + $i,
                    'policy' => $i === 18 ? 'continue' : 'deny',
                ]],
            ];
        }

        return array_map(function (array $product): array {
            return [
                'status' => 'active',
                'description_html' => '
Built for the Acme Fashion demo catalog.
',
                'vendor' => 'Acme',
                'published_at' => now(),
                ...$product,
            ];
        }, $base);
    }
}


sed -n '1,220p' database/seeders/InventoryItemSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

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


Output:
Chunk ID: 55c77b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 54
Output:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

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


sed -n '1,260p' database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        $this->call([
            OrganizationSeeder::class,
            StoreSeeder::class,
            StoreDomainSeeder::class,
            UserSeeder::class,
            StoreUserSeeder::class,
            StoreSettingsSeeder::class,
            CollectionSeeder::class,
            ProductSeeder::class,
            CustomerSeeder::class,
        ]);
    }
}


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

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        $this->call([
            OrganizationSeeder::class,
            StoreSeeder::class,
            StoreDomainSeeder::class,
            UserSeeder::class,
            StoreUserSeeder::class,
            StoreSettingsSeeder::class,
            CollectionSeeder::class,
            ProductSeeder::class,
            CustomerSeeder::class,
        ]);
    }
}


Input:
*** Begin Patch
*** Update File: tests/Feature/Catalog/CatalogFoundationTest.php
@@
+use App\Enums\InventoryPolicy;
 use App\Models\Collection;
+use App\Models\InventoryItem;
 use App\Models\Product;
+use App\Models\ProductVariant;
 use App\Models\Store;
@@
 test('catalog models are scoped to the resolved store', function () {
@@

     expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']);
 });
+
+test('variants create inventory for their product store regardless of resolved store scope', function () {
+    $resolvedStore = Store::factory()->create();
+    $productStore = Store::factory()->create();
+    $product = Product::factory()->create(['store_id' => $productStore->getKey()]);
+
+    app()->instance('current_store', $resolvedStore);
+
+    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
+    $inventory = InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->first();
+
+    expect($inventory)->not->toBeNull()
+        ->and($inventory->store_id)->toBe($productStore->getKey())
+        ->and($inventory->policy)->toBe(InventoryPolicy::Deny);
+});
*** End Patch


Success. Updated the following files:
M tests/Feature/Catalog/CatalogFoundationTest.php

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

+use App\Enums\InventoryPolicy;
 use App\Models\Collection;
+use App\Models\InventoryItem;
 use App\Models\Product;
+use App\Models\ProductVariant;
 use App\Models\Store;
@@ -36 +39,18 @@
 });
+
+test('variants create inventory for their product store regardless of resolved store scope', function () {
+    $resolvedStore = Store::factory()->create();
+    $productStore = Store::factory()->create();
+    $product = Product::factory()->create(['store_id' => $productStore->getKey()]);
+
+    app()->instance('current_store', $resolvedStore);
+
+    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
+    $inventory = InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->first();
+
+    expect($inventory)->not->toBeNull()
+        ->and($inventory->store_id)->toBe($productStore->getKey())
+        ->and($inventory->policy)->toBe(InventoryPolicy::Deny);
+});


Output: {"output":"Success. Updated the following files:\nM tests/Feature/Catalog/CatalogFoundationTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The targeted regression test now exercises the cross-store scope case directly. I’m running the catalog tests next; if that holds I’ll run the full suite and a fresh migration/seed.


php artisan test --compact tests/Feature/Catalog


Output:
Chunk ID: 28a38d
Wall time: 0.6451 seconds
Process exited with code 0
Original token count: 19
Output:

  ...........

  Tests:    11 passed (36 assertions)
  Duration: 0.43s


php artisan test --compact tests/Feature/Catalog

  ...........

  Tests:    11 passed (36 assertions)
  Duration: 0.43s


The catalog suite passes with the new invariant: 11 tests, 36 assertions. I’m moving to the full Pest suite now before formatting and seed verification.


Output:
Chunk ID: 7d7cd8
Wall time: 2.5602 seconds
Process exited with code 0
Original token count: 30
Output:

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

  Tests:    56 passed (157 assertions)
  Duration: 2.48s


The full suite passes: 56 tests, 157 assertions. I’m running Pint on the dirty PHP files now, then I’ll rerun the affected checks if it changes anything.


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


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


Pint reports the changed PHP files are clean. I’m verifying the schema and seed data from scratch with `migrate:fresh --seed` next.


php artisan migrate:fresh --seed --no-interaction


Output:
Chunk ID: 5f1dba
Wall time: 0.7667 seconds
Process exited with code 0
Original token count: 826
Output:

  Dropping all tables ............................................ 4.57ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.87ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.56ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.42ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.89ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.43ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.20ms DONE
  2026_05_03_211413_create_stores_table .......................... 4.17ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.48ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.18ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.36ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.33ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.19ms DONE
  2026_05_03_213856_create_products_table ........................ 0.55ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.28ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.29ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.58ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.24ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.31ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.39ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.36ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.55ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 6 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 265 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 0 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 2 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 2 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder .................................. 44 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 263 ms DONE


Question: SELECT (SELECT COUNT(*) FROM products) AS products, (SELECT COUNT(*) FROM product_variants) AS variants, (SELECT COUNT(*) FROM inventory_items) AS inventory_items, (SELECT COUNT(*) FROM collections) AS collections, (SELECT COUNT(*) FROM product_media) AS product_media


Question: SELECT (SELECT COUNT(*) FROM products) AS products, (SELECT COUNT(*) FROM product_variants) AS variants, (SELECT COUNT(*) FROM inventory_items) AS inventory_items, (SELECT COUNT(*) FROM collections) AS collections, (SELECT COUNT(*) FROM product_media) AS product_media


varchar

varchar


Output:
Wall time: 0.1647 seconds
Output:
[{"type":"text","text":"[{\"products\":20,\"variants\":22,\"inventory_items\":22,\"collections\":4,\"product_media\":20}]"}]


<subagent_notification>
{"agent_path":"019defd1-bb1f-7f30-a396-817d41a29e30","status":{"completed":"**Critical**\nNo critical findings found.\n\n**High**\n- Media processing is only a status flip, not the required pipeline. [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:20) only checks that the original exists and sets `ready`; it does not resize thumbnail/small/medium/large images, populate metadata, generate WebP, retry 3 times, or clean up files on media deletion as required by [specs/05-BUSINESS-LOGIC.md](/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md:1490).\n  Suggested verification: dispatch the job against a real/fake image and assert expected resized paths, metadata fields, failure status, retry behavior, and deletion cleanup.\n\n- Catalog seed data is materially non-compliant. Spec 07 requires two stores, Acme Fashion 20 products with 107 variants, Acme Electronics 5 products, exact handles/statuses/inventory, and no seeded `ProductMedia` rows. Current seeders create one store in [StoreSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/StoreSeeder.php:18), simplified products in [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:162), and media rows in [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:139). I verified current DB counts: 20 products, 22 variants, 20 media rows, 1 store.\n  Suggested verification: after `php artisan migrate:fresh --seed`, assert the exact product/variant/collection matrix from Spec 07, including Acme Electronics isolation and `product_media` count `0`.\n\n- `ProductService::create()` does not wire variant option selections. It creates options and variants separately in [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:45), but `createVariant()` ignores any selected option values and never inserts `variant_option_values` rows in [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:145). This leaves nested option/variant creation without the required variant matrix mapping.\n  Suggested verification: create a product with Size/Color options and explicit variant option selections through `ProductService`, then assert each variant has the expected pivot rows.\n\n- Progress Phase 2 scope includes admin catalog CRUD and storefront browsing, but no catalog routes/components exist. [progress.md](/Users/fabianwesner/Herd/shop/specs/progress.md:17) lists those in Catalog scope, while [routes/web.php](/Users/fabianwesner/Herd/shop/routes/web.php:9) only registers home, admin auth/dashboard, and account routes. `php artisan route:list --path=products --except-vendor` and `--path=collections` both returned no matching routes.\n  Suggested verification: add route/component smoke tests for admin products/collections and storefront collection/product browsing before claiming Phase 2 UI evidence.\n\n**Medium**\n- SQLite enum/check constraints are not actually enforced. The migrations use `$table->enum(...)`, for example [products](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213856_create_products_table.php:19), [inventory_items](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213901_create_inventory_items_table.php:20), and [product_media](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213904_create_product_media_table.php:17), but Boost schema inspection reported empty `check_constraints`. Spec 01 requires `CHECK IN` constraints for these fields.\n  Suggested verification: inspect `sqlite_master` table SQL or attempt direct invalid inserts for status/policy/type fields and confirm they fail.\n\n- Variant matrix no-option handling is partial. [VariantMatrixService.php](/Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php:28) returns after `ensureDefaultVariant()`, and [ensureDefaultVariant()](/Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php:72) exits if any variant already exists. If options are removed from a product with multiple variants, the service can leave multiple unmapped variants instead of the specified single default variant.\n  Suggested verification: create a product with multiple option variants, remove all options/values, rebuild, and assert only one default variant remains or define the intended archival behavior.\n\n- Progress evidence is stale and cannot support Phase 2 acceptance. [progress.md](/Users/fabianwesner/Herd/shop/specs/progress.md:9) still says the active slice is Phase 1, and [progress.md](/Users/fabianwesner/Herd/shop/specs/progress.md:33) says catalog services are missing despite catalog code/tests existing. Current catalog tests pass: `10 passed, 33 assertions`, but that evidence is not recorded.\n  Suggested verification: update progress only after mapping each Phase 2 requirement to concrete tests, schema inspection, seed assertions, and route/browser evidence."}}
</subagent_notification>


The Phase 2 QA agent returned no critical issues, but it found several high gaps: seed data fidelity, variant option pivoting, media processing, and missing catalog UI routes. I’ll address the data/service gaps first and keep the UI/media work clearly separated so we don’t claim Phase 2 prematurely.


**Findings**
- **High:** `ProductService::create()` and `update()` bypass the product lifecycle rules. Both methods accept `status`/`published_at` directly and never route through `transitionStatus()`, so callers can create an `active` product with no priced variant or update a referenced product back to `draft`. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:31), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:81), and the guarded lifecycle path at [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:103).

- **High:** Tenant isolation is incomplete for child catalog records. `ProductMedia`, `ProductVariant`, `ProductOption`, and `ProductOptionValue` have no `store_id` or global tenant scope, and `ProcessMediaUpload` mutates media by global ID only. Any route/job/action that accepts one of these IDs can cross store boundaries unless every caller manually scopes through the parent product. See [ProductMedia.php](/Users/fabianwesner/Herd/shop/app/Models/ProductMedia.php:11), [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:12), and [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:16).

- **High:** `inventory_items.store_id` can disagree with the variant’s product store, and nothing enforces the invariant. The migration has independent FKs only, while `InventoryItemFactory` can create a variant for one store and then allow a caller to override `store_id` to another. That makes store-scoped inventory rows capable of pointing at another tenant’s variant. See [create_inventory_items_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213901_create_inventory_items_table.php:16) and [InventoryItemFactory.php](/Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php:23).

- **Medium:** `ProcessMediaUpload` is effectively a status flip, not media processing. It does not verify the file is an image/video of the expected type, does not resize to thumbnail/medium/large, and does not update dimensions/output paths. A non-image file at `storage_key` can become `ready`. See [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:24).

- **Medium:** Seed data does not create the full variant matrix for products with options. Example: `Classic Cotton T-Shirt` defines 4 sizes and 3 colors but seeds only 3 variants, leaving most option combinations unavailable. This weakens storefront/cart/E2E coverage and contradicts the seeded catalog shape. See [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:171) and [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:175).

- **Medium:** Factories can generate invalid or flaky catalog records. `ProductFactory` creates active/published products without variants or inventory by default, while option/value factories default every sibling to `position = 0` despite unique `(parent_id, position)` indexes. See [ProductFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php:26), [ProductOptionFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductOptionFactory.php:20), and [create_product_options_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213857_create_product_options_table.php:20).

- **Medium:** SKU uniqueness is only checked in one private creation path. Direct `ProductVariant` saves, seeders, future variant update flows, and factory-created variants bypass the store-level SKU collision rule. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:145), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:216), and [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:20).

**Verification**
Ran `php artisan test --compact tests/Feature/Catalog` and `php artisan test --compact`; both passed. No files were edited.


Output: {"status":{"019defd1-d6cf-7513-98e2-d27eb4fe93a5":{"completed":"**Findings**\n- **High:** `ProductService::create()` and `update()` bypass the product lifecycle rules. Both methods accept `status`/`published_at` directly and never route through `transitionStatus()`, so callers can create an `active` product with no priced variant or update a referenced product back to `draft`. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:31), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:81), and the guarded lifecycle path at [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:103).\n\n- **High:** Tenant isolation is incomplete for child catalog records. `ProductMedia`, `ProductVariant`, `ProductOption`, and `ProductOptionValue` have no `store_id` or global tenant scope, and `ProcessMediaUpload` mutates media by global ID only. Any route/job/action that accepts one of these IDs can cross store boundaries unless every caller manually scopes through the parent product. See [ProductMedia.php](/Users/fabianwesner/Herd/shop/app/Models/ProductMedia.php:11), [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:12), and [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:16).\n\n- **High:** `inventory_items.store_id` can disagree with the variant’s product store, and nothing enforces the invariant. The migration has independent FKs only, while `InventoryItemFactory` can create a variant for one store and then allow a caller to override `store_id` to another. That makes store-scoped inventory rows capable of pointing at another tenant’s variant. See [create_inventory_items_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213901_create_inventory_items_table.php:16) and [InventoryItemFactory.php](/Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php:23).\n\n- **Medium:** `ProcessMediaUpload` is effectively a status flip, not media processing. It does not verify the file is an image/video of the expected type, does not resize to thumbnail/medium/large, and does not update dimensions/output paths. A non-image file at `storage_key` can become `ready`. See [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:24).\n\n- **Medium:** Seed data does not create the full variant matrix for products with options. Example: `Classic Cotton T-Shirt` defines 4 sizes and 3 colors but seeds only 3 variants, leaving most option combinations unavailable. This weakens storefront/cart/E2E coverage and contradicts the seeded catalog shape. See [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:171) and [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:175).\n\n- **Medium:** Factories can generate invalid or flaky catalog records. `ProductFactory` creates active/published products without variants or inventory by default, while option/value factories default every sibling to `position = 0` despite unique `(parent_id, position)` indexes. See [ProductFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php:26), [ProductOptionFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductOptionFactory.php:20), and [create_product_options_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213857_create_product_options_table.php:20).\n\n- **Medium:** SKU uniqueness is only checked in one private creation path. Direct `ProductVariant` saves, seeders, future variant update flows, and factory-created variants bypass the store-level SKU collision rule. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:145), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:216), and [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:20).\n\n**Verification**\nRan `php artisan test --compact tests/Feature/Catalog` and `php artisan test --compact`; both passed. No files were edited."}},"timed_out":false}


<subagent_notification>
{"agent_path":"019defd1-d6cf-7513-98e2-d27eb4fe93a5","status":{"completed":"**Findings**\n- **High:** `ProductService::create()` and `update()` bypass the product lifecycle rules. Both methods accept `status`/`published_at` directly and never route through `transitionStatus()`, so callers can create an `active` product with no priced variant or update a referenced product back to `draft`. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:31), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:81), and the guarded lifecycle path at [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:103).\n\n- **High:** Tenant isolation is incomplete for child catalog records. `ProductMedia`, `ProductVariant`, `ProductOption`, and `ProductOptionValue` have no `store_id` or global tenant scope, and `ProcessMediaUpload` mutates media by global ID only. Any route/job/action that accepts one of these IDs can cross store boundaries unless every caller manually scopes through the parent product. See [ProductMedia.php](/Users/fabianwesner/Herd/shop/app/Models/ProductMedia.php:11), [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:12), and [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:16).\n\n- **High:** `inventory_items.store_id` can disagree with the variant’s product store, and nothing enforces the invariant. The migration has independent FKs only, while `InventoryItemFactory` can create a variant for one store and then allow a caller to override `store_id` to another. That makes store-scoped inventory rows capable of pointing at another tenant’s variant. See [create_inventory_items_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213901_create_inventory_items_table.php:16) and [InventoryItemFactory.php](/Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php:23).\n\n- **Medium:** `ProcessMediaUpload` is effectively a status flip, not media processing. It does not verify the file is an image/video of the expected type, does not resize to thumbnail/medium/large, and does not update dimensions/output paths. A non-image file at `storage_key` can become `ready`. See [ProcessMediaUpload.php](/Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php:24).\n\n- **Medium:** Seed data does not create the full variant matrix for products with options. Example: `Classic Cotton T-Shirt` defines 4 sizes and 3 colors but seeds only 3 variants, leaving most option combinations unavailable. This weakens storefront/cart/E2E coverage and contradicts the seeded catalog shape. See [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:171) and [ProductSeeder.php](/Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php:175).\n\n- **Medium:** Factories can generate invalid or flaky catalog records. `ProductFactory` creates active/published products without variants or inventory by default, while option/value factories default every sibling to `position = 0` despite unique `(parent_id, position)` indexes. See [ProductFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php:26), [ProductOptionFactory.php](/Users/fabianwesner/Herd/shop/database/factories/ProductOptionFactory.php:20), and [create_product_options_table.php](/Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_213857_create_product_options_table.php:20).\n\n- **Medium:** SKU uniqueness is only checked in one private creation path. Direct `ProductVariant` saves, seeders, future variant update flows, and factory-created variants bypass the store-level SKU collision rule. See [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:145), [ProductService.php](/Users/fabianwesner/Herd/shop/app/Services/ProductService.php:216), and [ProductVariant.php](/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php:20).\n\n**Verification**\nRan `php artisan test --compact tests/Feature/Catalog` and `php artisan test --compact`; both passed. No files were edited."}}
</subagent_notification>


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.


eloquent model events creating saving validation throw exception

migration check constraint sqlite raw check

eloquent relationships through parent tenant scope tests


# 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]_