/bin/zsh
-lc
sed -n '1,260p' routes/api.php

<?php

use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
use App\Http\Controllers\Api\Admin\V1\OrderExportController as AdminOrderExportController;
use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
use App\Http\Controllers\Api\Admin\V1\PageController as AdminPageController;
use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
use App\Http\Controllers\Api\Admin\V1\StoreSettingsController as AdminStoreSettingsController;
use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
use App\Http\Controllers\Api\Admin\V1\ThemeController as AdminThemeController;
use App\Http\Controllers\Api\Admin\V1\ThemeSettingsController as AdminThemeSettingsController;
use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
use App\Http\Controllers\Api\Storefront\V1\CartController;
use App\Http\Controllers\Api\Storefront\V1\CartLineController;
use App\Http\Controllers\Api\Storefront\V1\CheckoutController;
use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
use Illuminate\Support\Facades\Route;

Route::middleware('store.resolve')
    ->prefix('storefront/v1')
    ->name('api.storefront.v1.')
    ->group(function (): void {
        Route::middleware('throttle:api.storefront')->group(function (): void {
            Route::post('carts', [CartController::class, 'store'])->name('carts.store');
            Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show');
            Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store');
            Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update');
            Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy');
        });

        Route::middleware('throttle:search')->group(function (): void {
            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
        });

        Route::middleware('throttle:analytics')->group(function (): void {
            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
        });

        Route::middleware('throttle:checkout')->group(function (): void {
            Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store');
            Route::get('checkouts/{checkout}', [CheckoutController::class, 'show'])->name('checkouts.show');
            Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'address'])->name('checkouts.address');
            Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('checkouts.shipping-method');
            Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount');
            Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'destroyDiscount'])->name('checkouts.discount.destroy');
            Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment-method');
            Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay');
            Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('admin/v1/stores/{store}')
    ->name('api.admin.v1.')
    ->group(function (): void {
        Route::middleware('admin.api:read-products')->group(function (): void {
            Route::get('products', [AdminProductController::class, 'index'])->name('products.index');
            Route::get('prod…649 chars truncated…pi:write-collections')->group(function (): void {
            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
        });

        Route::middleware('admin.api:read-discounts')->group(function (): void {
            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
        });

        Route::middleware('admin.api:write-discounts')->group(function (): void {
            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
        });

        Route::middleware('admin.api:read-content')->group(function (): void {
            Route::get('pages', [AdminPageController::class, 'index'])->name('pages.index');
        });

        Route::middleware('admin.api:write-content')->group(function (): void {
            Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store');
            Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update');
            Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy');
        });

        Route::middleware('admin.api:read-settings')->group(function (): void {
            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
            Route::get('settings', [AdminStoreSettingsController::class, 'show'])->name('settings.show');
            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
            Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
        });

        Route::middleware('admin.api:write-settings')->group(function (): void {
            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
            Route::put('settings', [AdminStoreSettingsController::class, 'update'])->name('settings.update');
            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
            Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
        });

        Route::middleware('admin.api:write-themes')->group(function (): void {
            Route::post('themes', [AdminThemeController::class, 'store'])->name('themes.store');
            Route::post('themes/{theme}/publish', [AdminThemeController::class, 'publish'])->name('themes.publish');
            Route::put('themes/{theme}/settings', [AdminThemeSettingsController::class, 'update'])->name('themes.settings.update');
        });

        Route::middleware('admin.api:read-analytics')->group(function (): void {
            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
        });

        Route::middleware('admin.api:read-orders')->group(function (): void {
            Route::post('exports/orders', [AdminOrderExportController::class, 'store'])->name('exports.orders.store');
            Route::get('exports/{dataExport}', [AdminOrderExportController::class, 'show'])->name('exports.show');
            Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
            Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
        });

        Route::middleware('admin.api:write-orders')->group(function (): void {
            Route::post('orders/{order}/fulfillments', [AdminOrderFulfillmentController::class, 'store'])->name('orders.fulfillments.store');
            Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('apps/v1/stores/{store}')
    ->name('api.apps.v1.')
    ->group(function (): void {
        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
    });


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

use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
use App\Http\Controllers\Api\Admin\V1\OrderExportController as AdminOrderExportController;
use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
use App\Http\Controllers\Api\Admin\V1\PageController as AdminPageController;
use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
use App\Http\Controllers\Api\Admin\V1\StoreSettingsController as AdminStoreSettingsController;
use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
use App\Http\Controllers\Api\Admin\V1\ThemeController as AdminThemeController;
use App\Http\Controllers\Api\Admin\V1\ThemeSettingsController as AdminThemeSettingsController;
use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
use App\Http\Controllers\Api\Storefront\V1\CartController;
use App\Http\Controllers\Api\Storefront\V1\CartLineController;
use App\Http\Controllers\Api\Storefront\V1\CheckoutController;
use App\Http\Controllers\Api\Storefront\V1\OrderController as StorefrontOrderController;
use App\Http\Controllers\Api\Storefront\V1\SearchController as StorefrontSearchController;
use Illuminate\Support\Facades\Route;

Route::middleware('store.resolve')
    ->prefix('storefront/v1')
    ->name('api.storefront.v1.')
    ->group(function (): void {
        Route::middleware('throttle:api.storefront')->group(function (): void {
            Route::post('carts', [CartController::class, 'store'])->name('carts.store');
            Route::get('carts/{cart}', [CartController::class, 'show'])->name('carts.show');
            Route::post('carts/{cart}/lines', [CartLineController::class, 'store'])->name('carts.lines.store');
            Route::put('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'update'])->name('carts.lines.update');
            Route::delete('carts/{cart}/lines/{cartLine}', [CartLineController::class, 'destroy'])->name('carts.lines.destroy');
        });

        Route::middleware('throttle:search')->group(function (): void {
            Route::get('search', [StorefrontSearchController::class, 'index'])->name('search.index');
            Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->name('search.suggest');
        });

        Route::middleware('throttle:analytics')->group(function (): void {
            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
        });

        Route::middleware('throttle:checkout')->group(function (): void {
            Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store');
            Route::get('checkouts/{checkout}', [CheckoutController::class, 'show'])->name('checkouts.show');
            Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'address'])->name('checkouts.address');
            Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'shippingMethod'])->name('checkouts.shipping-method');
            Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount');
            Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'destroyDiscount'])->name('checkouts.discount.destroy');
            Route::put('checkouts/{checkout}/payment-method', [CheckoutController::class, 'paymentMethod'])->name('checkouts.payment-method');
            Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay');
            Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('admin/v1/stores/{store}')
    ->name('api.admin.v1.')
    ->group(function (): void {
        Route::middleware('admin.api:read-products')->group(function (): void {
            Route::get('products', [AdminProductController::class, 'index'])->name('products.index');
            Route::get('products/{product}', [AdminProductController::class, 'show'])->name('products.show');
        });

        Route::middleware('admin.api:read-customers')->group(function (): void {
            Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
            Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
        });

        Route::middleware('admin.api:read-collections')->group(function (): void {
            Route::get('collections', [AdminCollectionController::class, 'index'])->name('collections.index');
        });

        Route::middleware('admin.api:write-collections')->group(function (): void {
            Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
            Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
            Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
        });

        Route::middleware('admin.api:read-discounts')->group(function (): void {
            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
        });

        Route::middleware('admin.api:write-discounts')->group(function (): void {
            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
        });

        Route::middleware('admin.api:read-content')->group(function (): void {
            Route::get('pages', [AdminPageController::class, 'index'])->name('pages.index');
        });

        Route::middleware('admin.api:write-content')->group(function (): void {
            Route::post('pages', [AdminPageController::class, 'store'])->name('pages.store');
            Route::put('pages/{page}', [AdminPageController::class, 'update'])->name('pages.update');
            Route::delete('pages/{page}', [AdminPageController::class, 'destroy'])->name('pages.destroy');
        });

        Route::middleware('admin.api:read-settings')->group(function (): void {
            Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
            Route::get('settings', [AdminStoreSettingsController::class, 'show'])->name('settings.show');
            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
            Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
        });

        Route::middleware('admin.api:write-settings')->group(function (): void {
            Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
            Route::put('settings', [AdminStoreSettingsController::class, 'update'])->name('settings.update');
            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
            Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
        });

        Route::middleware('admin.api:write-themes')->group(function (): void {
            Route::post('themes', [AdminThemeController::class, 'store'])->name('themes.store');
            Route::post('themes/{theme}/publish', [AdminThemeController::class, 'publish'])->name('themes.publish');
            Route::put('themes/{theme}/settings', [AdminThemeSettingsController::class, 'update'])->name('themes.settings.update');
        });

        Route::middleware('admin.api:read-analytics')->group(function (): void {
            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
        });

        Route::middleware('admin.api:read-orders')->group(function (): void {
            Route::post('exports/orders', [AdminOrderExportController::class, 'store'])->name('exports.orders.store');
            Route::get('exports/{dataExport}', [AdminOrderExportController::class, 'show'])->name('exports.show');
            Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
            Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
        });

        Route::middleware('admin.api:write-orders')->group(function (): void {
            Route::post('orders/{order}/fulfillments', [AdminOrderFulfillmentController::class, 'store'])->name('orders.fulfillments.store');
            Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
        });
    });

Route::middleware('throttle:60,1')
    ->prefix('apps/v1/stores/{store}')
    ->name('api.apps.v1.')
    ->group(function (): void {
        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
    });


rg -n "ProductMedia|temporaryUploadUrl|presign|media" app database tests specs/02-API-ROUTES.md specs/03-ADMIN-UI.md specs/07-SEEDERS-AND-TEST-DATA.md -S

rg -n 'ProductMedia|temporaryUploadUrl|presign|media' app database tests specs/02-API-ROUTES.md specs/03-ADMIN-UI.md specs/07-SEEDERS-AND-TEST-DATA.md -S

Question: ProductMedia|temporaryUploadUrl|presign|media

specs/07-SEEDERS-AND-TEST-DATA.md:1431:**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.
specs/07-SEEDERS-AND-TEST-DATA.md:2218:Every variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/02-API-ROUTES.md:50:| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1539:Returns the full product resource with nested variants, options, and media.
specs/02-API-ROUTES.md:1549:Get a single product with all variants, options, media, and collections.
specs/02-API-ROUTES.md:1613:    "media": [
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1724:  "media_id": 2,
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
tests/Feature/Catalog/CatalogFoundationTest.php:7:use App\Models\ProductMedia;
tests/Feature/Catalog/CatalogFoundationTest.php:29:        ->and(ProductMedia::withoutGlobalScopes()->count())->toBe(0)
tests/Feature/Catalog/CatalogFoundationTest.php:61:    ProductMedia::factory()->create(['product_id' => $firstProduct->getKey()]);
tests/Feature/Catalog/CatalogFoundationTest.php:62:    ProductMedia::factory()->create(['product_id' => $secondProduct->getKey()]);
tests/Feature/Catalog/CatalogFoundationTest.php:69:        ->and(ProductMedia::query()->pluck('product_id')->all())->toBe([$firstProduct->getKey()]);
tests/Browser/Storefront/ResponsiveTest.php:114:    const media = columns[0].getBoundingClientRect();
tests/Browser/Storefront/ResponsiveTest.php:117:    return Math.abs(media.left - details.left) <= 2 && details.top > media.bottom;
tests/Feature/Catalog/MediaProcessingTest.php:6:use App\Models\ProductMedia;
tests/Feature/Catalog/MediaProcessingTest.php:13:test('media processing generates resized files and cleans them up on delete', function () {
tests/Feature/Catalog/MediaProcessingTest.php:18:    $media = ProductMedia::factory()->processing()->create([
tests/Feature/Catalog/MediaProcessingTest.php:20:        'storage_key' => 'media/originals/test.png',
tests/Feature/Catalog/MediaProcessingTest.php:27:    Storage::disk('public')->put($media->storage_key, catalogPngImageContents());
tests/Feature/Catalog/MediaProcessingTest.php:29:    (new ProcessMediaUpload($media->getKey(), $store->getKey()))->handle();
tests/Feature/Catalog/MediaProcessingTest.php:31:    $media->refresh();
tests/Feature/Catalog/MediaProcessingTest.php:33:    expect($media->status)->toBe(MediaStatus::Ready)
tests/Feature/Catalog/MediaProcessingTest.php:34:        ->and($media->width)->toBe(20)
tests/Feature/Catalog/MediaProcessingTest.php:35:        ->and($media->height)->toBe(10)
tests/Feature/Catalog/MediaProcessingTest.php:36:        ->and($media->mime_type)->toBe('image/png')
tests/Feature/Catalog/MediaProcessingTest.php:37:        ->and($media->byte_size)->toBeGreaterThan(0);
tests/Feature/Catalog/MediaProcessingTest.php:40:        Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.png");
tests/Feature/Catalog/MediaProcessingTest.php:43:            Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.webp"…10629 chars truncated…lect($this->media)
app/Livewire/Admin/Products/Form.php:338:                ->reject(fn (int $id): bool => $id === $mediaId)
app/Livewire/Admin/Products/Form.php:377:        $this->media = $product->media
app/Livewire/Admin/Products/Form.php:378:            ->map(fn (ProductMedia $media): array => [
app/Livewire/Admin/Products/Form.php:379:                'id' => $media->getKey(),
app/Livewire/Admin/Products/Form.php:380:                'url' => Storage::disk('public')->url($media->storage_key),
app/Livewire/Admin/Products/Form.php:381:                'exists' => Storage::disk('public')->exists($media->storage_key),
app/Livewire/Admin/Products/Form.php:382:                'altText' => (string) $media->alt_text,
app/Livewire/Admin/Products/Form.php:383:                'position' => $media->position,
app/Livewire/Admin/Products/Form.php:384:                'status' => $media->status->value,
app/Livewire/Admin/Products/Form.php:743:        $maxPosition = ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:751:                "media/originals/{$product->getKey()}",
app/Livewire/Admin/Products/Form.php:756:            $media = ProductMedia::withoutGlobalScopes()->create([
app/Livewire/Admin/Products/Form.php:765:            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
app/Livewire/Admin/Products/Form.php:782:    private function mediaRecord(int $mediaId): ProductMedia
app/Livewire/Admin/Products/Form.php:784:        return ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:786:            ->findOrFail($mediaId);
app/Livewire/Admin/Products/Form.php:789:    private function mediaIndex(int $mediaId): int
app/Livewire/Admin/Products/Form.php:791:        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);
app/Livewire/Admin/Products/Form.php:798:    private function refreshProductMedia(): void
app/Livewire/Admin/Products/Form.php:800:        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
app/Models/ProductMedia.php:13:class ProductMedia extends Model
app/Models/ProductMedia.php:15:    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
app/Models/ProductMedia.php:65:        static::deleted(function (ProductMedia $media): void {
app/Models/ProductMedia.php:68:            $disk->delete($media->storage_key);
app/Models/ProductMedia.php:69:            $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}");
app/Models/Product.php:66:     * @return HasMany<ProductMedia, $this>
app/Models/Product.php:68:    public function media(): HasMany
app/Models/Product.php:70:        return $this->hasMany(ProductMedia::class)->orderBy('position');
app/Http/Controllers/Api/Admin/V1/ProductController.php:30:                'media',
app/Http/Controllers/Api/Admin/V1/ProductController.php:81:            'media',
app/Http/Resources/Admin/V1/ProductResource.php:5:use App\Models\ProductMedia;
app/Http/Resources/Admin/V1/ProductResource.php:34:            'featured_image' => $this->when($this->relationLoaded('media'), function (): ?array {
app/Http/Resources/Admin/V1/ProductResource.php:35:                $media = $this->media->first();
app/Http/Resources/Admin/V1/ProductResource.php:37:                return $media instanceof ProductMedia ? $this->mediaPayload($media) : null;
app/Http/Resources/Admin/V1/ProductResource.php:74:            'media' => $this->whenLoaded('media', fn () => $this->media->map(fn (ProductMedia $media): array => $this->mediaPayload($media))->values()),
app/Http/Resources/Admin/V1/ProductResource.php:89:    private function mediaPayload(ProductMedia $media): array
app/Http/Resources/Admin/V1/ProductResource.php:92:            'id' => $media->id,
app/Http/Resources/Admin/V1/ProductResource.php:93:            'type' => $media->type?->value,
app/Http/Resources/Admin/V1/ProductResource.php:94:            'storage_key' => $media->storage_key,
app/Http/Resources/Admin/V1/ProductResource.php:95:            'url' => Storage::disk('public')->url($media->storage_key),
app/Http/Resources/Admin/V1/ProductResource.php:96:            'alt_text' => $media->alt_text,
app/Http/Resources/Admin/V1/ProductResource.php:97:            'width' => $media->width,
app/Http/Resources/Admin/V1/ProductResource.php:98:            'height' => $media->height,
app/Http/Resources/Admin/V1/ProductResource.php:99:            'mime_type' => $media->mime_type,
app/Http/Resources/Admin/V1/ProductResource.php:100:            'byte_size' => $media->byte_size,
app/Http/Resources/Admin/V1/ProductResource.php:101:            'position' => $media->position,
app/Http/Resources/Admin/V1/ProductResource.php:102:            'status' => $media->status?->value,
app/Http/Resources/Storefront/V1/SearchProductResource.php:32:            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,


Output:
Chunk ID: 3b7731
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 5158
Output:
specs/07-SEEDERS-AND-TEST-DATA.md:1431:**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.
specs/07-SEEDERS-AND-TEST-DATA.md:2218:Every variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/02-API-ROUTES.md:50:| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1539:Returns the full product resource with nested variants, options, and media.
specs/02-API-ROUTES.md:1549:Get a single product with all variants, options, media, and collections.
specs/02-API-ROUTES.md:1613:    "media": [
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1724:  "media_id": 2,
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
tests/Feature/Catalog/CatalogFoundationTest.php:7:use App\Models\ProductMedia;
tests/Feature/Catalog/CatalogFoundationTest.php:29:        ->and(ProductMedia::withoutGlobalScopes()->count())->toBe(0)
tests/Feature/Catalog/CatalogFoundationTest.php:61:    ProductMedia::factory()->create(['product_id' => $firstProduct->getKey()]);
tests/Feature/Catalog/CatalogFoundationTest.php:62:    ProductMedia::factory()->create(['product_id' => $secondProduct->getKey()]);
tests/Feature/Catalog/CatalogFoundationTest.php:69:        ->and(ProductMedia::query()->pluck('product_id')->all())->toBe([$firstProduct->getKey()]);
tests/Browser/Storefront/ResponsiveTest.php:114:    const media = columns[0].getBoundingClientRect();
tests/Browser/Storefront/ResponsiveTest.php:117:    return Math.abs(media.left - details.left) <= 2 && details.top > media.bottom;
tests/Feature/Catalog/MediaProcessingTest.php:6:use App\Models\ProductMedia;
tests/Feature/Catalog/MediaProcessingTest.php:13:test('media processing generates resized files and cleans them up on delete', function () {
tests/Feature/Catalog/MediaProcessingTest.php:18:    $media = ProductMedia::factory()->processing()->create([
tests/Feature/Catalog/MediaProcessingTest.php:20:        'storage_key' => 'media/originals/test.png',
tests/Feature/Catalog/MediaProcessingTest.php:27:    Storage::disk('public')->put($media->storage_key, catalogPngImageContents());
tests/Feature/Catalog/MediaProcessingTest.php:29:    (new ProcessMediaUpload($media->getKey(), $store->getKey()))->handle();
tests/Feature/Catalog/MediaProcessingTest.php:31:    $media->refresh();
tests/Feature/Catalog/MediaProcessingTest.php:33:    expect($media->status)->toBe(MediaStatus::Ready)
tests/Feature/Catalog/MediaProcessingTest.php:34:        ->and($media->width)->toBe(20)
tests/Feature/Catalog/MediaProcessingTest.php:35:        ->and($media->height)->toBe(10)
tests/Feature/Catalog/MediaProcessingTest.php:36:        ->and($media->mime_type)->toBe('image/png')
tests/Feature/Catalog/MediaProcessingTest.php:37:        ->and($media->byte_size)->toBeGreaterThan(0);
tests/Feature/Catalog/MediaProcessingTest.php:40:        Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.png");
tests/Feature/Catalog/MediaProcessingTest.php:43:            Storage::disk('public')->assertExists("media/{$product->getKey()}/{$media->getKey()}/{$size}.webp");
tests/Feature/Catalog/MediaProcessingTest.php:47:    $media->delete();
tests/Feature/Catalog/MediaProcessingTest.php:49:    Storage::disk('public')->assertMissing('media/originals/test.png');
tests/Feature/Catalog/MediaProcessingTest.php:50:    Storage::disk('public')->assertMissing("media/{$product->getKey()}/{$media->getKey()}/thumbnail.png");
tests/Feature/Catalog/MediaProcessingTest.php:53:test('media processing marks invalid image uploads as failed', function () {
tests/Feature/Catalog/MediaProcessingTest.php:58:    $media = ProductMedia::factory()->processing()->create([
tests/Feature/Catalog/MediaProcessingTest.php:60:        'storage_key' => 'media/originals/not-image.txt',
tests/Feature/Catalog/MediaProcessingTest.php:63:    Storage::disk('public')->put($media->storage_key, 'not an image');
tests/Feature/Catalog/MediaProcessingTest.php:65:    expect(fn () => (new ProcessMediaUpload($media->getKey(), $store->getKey()))->handle())
tests/Feature/Catalog/MediaProcessingTest.php:68:    expect($media->refresh()->status)->toBe(MediaStatus::Failed);
app/Services/SearchService.php:78:                    'image_url' => $product->media->first()?->storage_key,
app/Services/SearchService.php:368:            ->with(['store', 'variants.inventoryItem', 'media'])
app/Services/OrderService.php:76:                $paidImmediately = $paymentResult->status === PaymentStatus::Captured;
app/Services/OrderService.php:85:                    'status' => $paidImmediately ? OrderStatus::Paid : OrderStatus::Pending,
app/Services/OrderService.php:86:                    'financial_status' => $paidImmediately ? FinancialStatus::Paid : FinancialStatus::Pending,
app/Services/OrderService.php:112:                if ($paidImmediately) {
app/Services/OrderService.php:127:                if ($paidImmediately) {
app/Services/OrderService.php:135:                if ($paidImmediately) {
database/factories/ProductMediaFactory.php:11: * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductMedia>
database/factories/ProductMediaFactory.php:13:class ProductMediaFactory extends Factory
tests/Feature/Admin/ProductMediaManagementTest.php:8:use App\Models\ProductMedia;
tests/Feature/Admin/ProductMediaManagementTest.php:29:test('admin product form uploads images and queues media processing', function (): void {
tests/Feature/Admin/ProductMediaManagementTest.php:51:    $media = ProductMedia::withoutGlobalScopes()
tests/Feature/Admin/ProductMediaManagementTest.php:55:    expect($media->status)->toBe(MediaStatus::Processing)
tests/Feature/Admin/ProductMediaManagementTest.php:56:        ->and($media->position)->toBe(0)
tests/Feature/Admin/ProductMediaManagementTest.php:57:        ->and($media->alt_text)->toBe('Media Test Product')
tests/Feature/Admin/ProductMediaManagementTest.php:58:        ->and($media->storage_key)->toStartWith("media/originals/{$product->getKey()}/");
tests/Feature/Admin/ProductMediaManagementTest.php:60:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:62:    Queue::assertPushed(ProcessMediaUpload::class, function (ProcessMediaUpload $job) use ($media, $store): bool {
tests/Feature/Admin/ProductMediaManagementTest.php:63:        return $job->productMediaId === $media->getKey()
tests/Feature/Admin/ProductMediaManagementTest.php:68:test('admin product form attaches selected media when creating a product', function (): void {
tests/Feature/Admin/ProductMediaManagementTest.php:80:        ->set('handle', 'created-media-product')
tests/Feature/Admin/ProductMediaManagementTest.php:92:        ->where('handle', 'created-media-product')
tests/Feature/Admin/ProductMediaManagementTest.php:94:    $media = ProductMedia::withoutGlobalScopes()
tests/Feature/Admin/ProductMediaManagementTest.php:98:    Storage::disk('public')->assertExists($media->storage_key);
tests/Feature/Admin/ProductMediaManagementTest.php:102:test('admin product form rejects non image media uploads', function (): void {
tests/Feature/Admin/ProductMediaManagementTest.php:120:    expect(ProductMedia::withoutGlobalScopes()->where('product_id', $product->getKey())->count())->toBe(0);
tests/Feature/Admin/ProductMediaManagementTest.php:125:test('admin product form updates alt text reorders and deletes media', function (): void {
tests/Feature/Admin/ProductMediaManagementTest.php:132:    $media = collect([0, 1, 2])->map(function (int $position) use ($product): ProductMedia {
tests/Feature/Admin/ProductMediaManagementTest.php:133:        $item = ProductMedia::factory()->create([
tests/Feature/Admin/ProductMediaManagementTest.php:135:            'storage_key' => "media/originals/{$product->getKey()}/{$position}.png",
tests/Feature/Admin/ProductMediaManagementTest.php:149:        ->set('media.1.altText', 'Updated sleeve detail')
tests/Feature/Admin/ProductMediaManagementTest.php:150:        ->call('updateMediaAlt', $media[1]->getKey())
tests/Feature/Admin/ProductMediaManagementTest.php:153:    expect($media[1]->refresh()->alt_text)->toBe('Updated sleeve detail');
tests/Feature/Admin/ProductMediaManagementTest.php:155:    $component->call('moveMedia', $media[2]->getKey(), 'up');
tests/Feature/Admin/ProductMediaManagementTest.php:157:    expect($media[0]->refresh()->position)->toBe(0)
tests/Feature/Admin/ProductMediaManagementTest.php:158:        ->and($media[2]->refresh()->position)->toBe(1)
tests/Feature/Admin/ProductMediaManagementTest.php:159:        ->and($media[1]->refresh()->position)->toBe(2);
tests/Feature/Admin/ProductMediaManagementTest.php:161:    $component->call('removeMedia', $media[2]->getKey());
tests/Feature/Admin/ProductMediaManagementTest.php:163:    $this->assertModelMissing($media[2]);
tests/Feature/Admin/ProductMediaManagementTest.php:164:    Storage::disk('public')->assertMissing($media[2]->storage_key);
database/seeders/ProductMediaSeeder.php:7:class ProductMediaSeeder extends Seeder
database/migrations/2026_05_03_213904_create_product_media_table.php:14:        Schema::create('product_media', function (Blueprint $table) {
database/migrations/2026_05_03_213904_create_product_media_table.php:39:        Schema::dropIfExists('product_media');
app/Jobs/ProcessMediaUpload.php:7:use App\Models\ProductMedia;
app/Jobs/ProcessMediaUpload.php:39:        $media = $this->media();
app/Jobs/ProcessMediaUpload.php:42:            $this->process($media);
app/Jobs/ProcessMediaUpload.php:44:            $media->forceFill([
app/Jobs/ProcessMediaUpload.php:49:                'product_media_id' => $media->getKey(),
app/Jobs/ProcessMediaUpload.php:50:                'storage_key' => $media->storage_key,
app/Jobs/ProcessMediaUpload.php:58:    private function media(): ProductMedia
app/Jobs/ProcessMediaUpload.php:60:        $query = ProductMedia::withoutGlobalScopes()->whereKey($this->productMediaId);
app/Jobs/ProcessMediaUpload.php:73:    private function process(ProductMedia $media): void
app/Jobs/ProcessMediaUpload.php:77:        if (! $disk->exists($media->storage_key)) {
app/Jobs/ProcessMediaUpload.php:81:        $contents = $disk->get($media->storage_key);
app/Jobs/ProcessMediaUpload.php:83:        $byteSize = $disk->size($media->storage_key);
app/Jobs/ProcessMediaUpload.php:85:        $dimensions = match ($media->type) {
app/Jobs/ProcessMediaUpload.php:86:            MediaType::Image => $this->processImage($media, $contents, $mimeType),
app/Jobs/ProcessMediaUpload.php:90:        $media->forceFill([
app/Jobs/ProcessMediaUpload.php:102:    private function processImage(ProductMedia $media, string $contents, string $mimeType): array
app/Jobs/ProcessMediaUpload.php:135:            $basePath = "media/{$media->product_id}/{$media->getKey()}/{$size}";
app/Jobs/ProcessMediaUpload.php:197:            throw new RuntimeException('Unable to encode resized media image.');
app/Jobs/ProcessMediaUpload.php:208:            throw new RuntimeException('Unable to determine media MIME type.');
database/seeders/ProductSeeder.php:8:use App\Models\ProductMedia;
database/seeders/ProductSeeder.php:68:        ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:13:use App\Models\ProductMedia;
app/Livewire/Admin/Products/Form.php:68:    public array $media = [];
app/Livewire/Admin/Products/Form.php:82:            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
app/Livewire/Admin/Products/Form.php:232:        $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
app/Livewire/Admin/Products/Form.php:250:        $this->product = $product->refresh()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
app/Livewire/Admin/Products/Form.php:257:    public function updateMediaAlt(int $mediaId): void
app/Livewire/Admin/Products/Form.php:259:        $index = $this->mediaIndex($mediaId);
app/Livewire/Admin/Products/Form.php:262:            "media.{$index}.altText" => ['nullable', 'string', 'max:255'],
app/Livewire/Admin/Products/Form.php:265:        $media = $this->mediaRecord($mediaId);
app/Livewire/Admin/Products/Form.php:266:        $altText = trim((string) $this->media[$index]['altText']);
app/Livewire/Admin/Products/Form.php:268:        $media->forceFill([
app/Livewire/Admin/Products/Form.php:272:        $this->refreshProductMedia();
app/Livewire/Admin/Products/Form.php:277:    public function moveMedia(int $mediaId, string $direction): void
app/Livewire/Admin/Products/Form.php:279:        $ids = collect($this->media)->pluck('id')->map(fn (int $id): int => $id)->all();
app/Livewire/Admin/Products/Form.php:280:        $index = array_search($mediaId, $ids, true);
app/Livewire/Admin/Products/Form.php:307:        $validIds = ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:314:            ->map(fn (mixed $mediaId): int => (int) $mediaId)
app/Livewire/Admin/Products/Form.php:319:            foreach ($orderedIds as $position => $mediaId) {
app/Livewire/Admin/Products/Form.php:320:                ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:321:                    ->whereKey($mediaId)
app/Livewire/Admin/Products/Form.php:326:        $this->refreshProductMedia();
app/Livewire/Admin/Products/Form.php:329:    public function removeMedia(int $mediaId): void
app/Livewire/Admin/Products/Form.php:331:        $media = $this->mediaRecord($mediaId);
app/Livewire/Admin/Products/Form.php:333:        $media->delete();
app/Livewire/Admin/Products/Form.php:336:            collect($this->media)
app/Livewire/Admin/Products/Form.php:338:                ->reject(fn (int $id): bool => $id === $mediaId)
app/Livewire/Admin/Products/Form.php:377:        $this->media = $product->media
app/Livewire/Admin/Products/Form.php:378:            ->map(fn (ProductMedia $media): array => [
app/Livewire/Admin/Products/Form.php:379:                'id' => $media->getKey(),
app/Livewire/Admin/Products/Form.php:380:                'url' => Storage::disk('public')->url($media->storage_key),
app/Livewire/Admin/Products/Form.php:381:                'exists' => Storage::disk('public')->exists($media->storage_key),
app/Livewire/Admin/Products/Form.php:382:                'altText' => (string) $media->alt_text,
app/Livewire/Admin/Products/Form.php:383:                'position' => $media->position,
app/Livewire/Admin/Products/Form.php:384:                'status' => $media->status->value,
app/Livewire/Admin/Products/Form.php:743:        $maxPosition = ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:751:                "media/originals/{$product->getKey()}",
app/Livewire/Admin/Products/Form.php:756:            $media = ProductMedia::withoutGlobalScopes()->create([
app/Livewire/Admin/Products/Form.php:765:            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
app/Livewire/Admin/Products/Form.php:782:    private function mediaRecord(int $mediaId): ProductMedia
app/Livewire/Admin/Products/Form.php:784:        return ProductMedia::withoutGlobalScopes()
app/Livewire/Admin/Products/Form.php:786:            ->findOrFail($mediaId);
app/Livewire/Admin/Products/Form.php:789:    private function mediaIndex(int $mediaId): int
app/Livewire/Admin/Products/Form.php:791:        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);
app/Livewire/Admin/Products/Form.php:798:    private function refreshProductMedia(): void
app/Livewire/Admin/Products/Form.php:800:        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
app/Models/ProductMedia.php:13:class ProductMedia extends Model
app/Models/ProductMedia.php:15:    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
app/Models/ProductMedia.php:65:        static::deleted(function (ProductMedia $media): void {
app/Models/ProductMedia.php:68:            $disk->delete($media->storage_key);
app/Models/ProductMedia.php:69:            $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}");
app/Models/Product.php:66:     * @return HasMany<ProductMedia, $this>
app/Models/Product.php:68:    public function media(): HasMany
app/Models/Product.php:70:        return $this->hasMany(ProductMedia::class)->orderBy('position');
app/Http/Controllers/Api/Admin/V1/ProductController.php:30:                'media',
app/Http/Controllers/Api/Admin/V1/ProductController.php:81:            'media',
app/Http/Resources/Admin/V1/ProductResource.php:5:use App\Models\ProductMedia;
app/Http/Resources/Admin/V1/ProductResource.php:34:            'featured_image' => $this->when($this->relationLoaded('media'), function (): ?array {
app/Http/Resources/Admin/V1/ProductResource.php:35:                $media = $this->media->first();
app/Http/Resources/Admin/V1/ProductResource.php:37:                return $media instanceof ProductMedia ? $this->mediaPayload($media) : null;
app/Http/Resources/Admin/V1/ProductResource.php:74:            'media' => $this->whenLoaded('media', fn () => $this->media->map(fn (ProductMedia $media): array => $this->mediaPayload($media))->values()),
app/Http/Resources/Admin/V1/ProductResource.php:89:    private function mediaPayload(ProductMedia $media): array
app/Http/Resources/Admin/V1/ProductResource.php:92:            'id' => $media->id,
app/Http/Resources/Admin/V1/ProductResource.php:93:            'type' => $media->type?->value,
app/Http/Resources/Admin/V1/ProductResource.php:94:            'storage_key' => $media->storage_key,
app/Http/Resources/Admin/V1/ProductResource.php:95:            'url' => Storage::disk('public')->url($media->storage_key),
app/Http/Resources/Admin/V1/ProductResource.php:96:            'alt_text' => $media->alt_text,
app/Http/Resources/Admin/V1/ProductResource.php:97:            'width' => $media->width,
app/Http/Resources/Admin/V1/ProductResource.php:98:            'height' => $media->height,
app/Http/Resources/Admin/V1/ProductResource.php:99:            'mime_type' => $media->mime_type,
app/Http/Resources/Admin/V1/ProductResource.php:100:            'byte_size' => $media->byte_size,
app/Http/Resources/Admin/V1/ProductResource.php:101:            'position' => $media->position,
app/Http/Resources/Admin/V1/ProductResource.php:102:            'status' => $media->status?->value,
app/Http/Resources/Storefront/V1/SearchProductResource.php:32:            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,


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


sed -n '740,840p' app/Livewire/Admin/Products/Form.php


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


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

            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
        };
    }

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

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

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

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

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

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

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

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

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

    /**
     * @param  array<string, mixed>  $data
     */
    private function requestedProductStatus(array $data): ?ProductStatus
    {
        if (! array_key_exists('status', $data)) {
            return null;
        }

        if ($data['status'] instanceof ProductStatus) {
            return $data['status'];
        }

        return ProductStatus::from((string) $data['status']);
    }
}


sed -n '740,840p' app/Livewire/Admin/Products/Form.php

    private function storeNewMedia(Product $product): void
    {
        $maxPosition = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->max('position');
        $position = $maxPosition === null ? 0 : ((int) $maxPosition) + 1;

        foreach ($this->newMedia as $file) {
            $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'jpg');
            $storageKey = $file->storeAs(
                "media/originals/{$product->getKey()}",
                Str::uuid().'.'.$extension,
                'public',
            );

            $media = ProductMedia::withoutGlobalScopes()->create([
                'product_id' => $product->getKey(),
                'type' => MediaType::Image,
                'storage_key' => $storageKey,
                'alt_text' => $product->title,
                'position' => $position++,
                'status' => MediaStatus::Processing,
            ]);

            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
        }

        $this->reset('newMedia');
    }

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

        abort_unless($store instanceof Store && $this->product instanceof Product, 404);

        return Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->findOrFail($this->product->getKey());
    }

    private function mediaRecord(int $mediaId): ProductMedia
    {
        return ProductMedia::withoutGlobalScopes()
            ->where('product_id', $this->productForAction()->getKey())
            ->findOrFail($mediaId);
    }

    private function mediaIndex(int $mediaId): int
    {
        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);

        abort_if($index === false, 404);

        return (int) $index;
    }

    private function refreshProductMedia(): void
    {
        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);
    }
}


Output:
Chunk ID: cd44ba
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 441
Output:
            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
        };
    }

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

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

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

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

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

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

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

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

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

    /**
     * @param  array<string, mixed>  $data
     */
    private function requestedProductStatus(array $data): ?ProductStatus
    {
        if (! array_key_exists('status', $data)) {
            return null;
        }

        if ($data['status'] instanceof ProductStatus) {
            return $data['status'];
        }

        return ProductStatus::from((string) $data['status']);
    }
}


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

    private function storeNewMedia(Product $product): void
    {
        $maxPosition = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->max('position');
        $position = $maxPosition === null ? 0 : ((int) $maxPosition) + 1;

        foreach ($this->newMedia as $file) {
            $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'jpg');
            $storageKey = $file->storeAs(
                "media/originals/{$product->getKey()}",
                Str::uuid().'.'.$extension,
                'public',
            );

            $media = ProductMedia::withoutGlobalScopes()->create([
                'product_id' => $product->getKey(),
                'type' => MediaType::Image,
                'storage_key' => $storageKey,
                'alt_text' => $product->title,
                'position' => $position++,
                'status' => MediaStatus::Processing,
            ]);

            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
        }

        $this->reset('newMedia');
    }

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

        abort_unless($store instanceof Store && $this->product instanceof Product, 404);

        return Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->findOrFail($this->product->getKey());
    }

    private function mediaRecord(int $mediaId): ProductMedia
    {
        return ProductMedia::withoutGlobalScopes()
            ->where('product_id', $this->productForAction()->getKey())
            ->findOrFail($mediaId);
    }

    private function mediaIndex(int $mediaId): int
    {
        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);

        abort_if($index === false, 404);

        return (int) $index;
    }

    private function refreshProductMedia(): void
    {
        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);
    }
}


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

<?php

namespace App\Models;

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

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

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

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

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

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

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

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

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

            if (! $storeId) {
                return;
            }

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

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

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

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

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

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

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

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

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

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


/bin/zsh
-lc
sed -n '1,180p' 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: 414206
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',
        ];
    }
}


Output:
Chunk ID: 1ff952
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1140
Output:
<?php

namespace App\Models;

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

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

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

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

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

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

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

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

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

            if (! $storeId) {
                return;
            }

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

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

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

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

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

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

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

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

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

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


sed -n '1380,1745p' specs/02-API-ROUTES.md


sed -n '1,180p' database/migrations/2026_05_03_213904_create_product_media_table.php


sed -n '1,180p' app/Models/ProductMedia.php


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


sed -n '1,180p' database/migrations/2026_05_03_213904_create_product_media_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('product_media', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->enum('type', ['image', 'video'])->default('image');
            $table->string('storage_key');
            $table->string('alt_text')->nullable();
            $table->unsignedInteger('width')->nullable();
            $table->unsignedInteger('height')->nullable();
            $table->string('mime_type')->nullable();
            $table->unsignedInteger('byte_size')->nullable();
            $table->unsignedInteger('position')->default(0);
            $table->enum('status', ['processing', 'ready', 'failed'])->default('processing');
            $table->timestamp('created_at')->nullable();

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

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


sed -n '1380,1745p' specs/02-API-ROUTES.md

"write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 42,
    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "
A comfortable cotton t-shirt.
",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min:…1035 chars truncated…   "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "<p>A comfortable cotton t-shirt.</p>",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors (invalid content type, file too large)

---

### 3.3 Collections

#### Admin Collections Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |


Output:
Chunk ID: 750f6c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2759
Output:
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 42,
    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "
A comfortable cotton t-shirt.
",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min: 0 |
| `variants[].requires_shipping` | boolean | No | Default: true |
| `variants[].is_default` | boolean | No | Exactly one variant must be default. |
| `variants[].position` | integer | No | Auto-assigned if not provided. |
| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |
| `variants[].option_values` | array | No | Must match defined options. |
| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |
| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |

**Success response:** `201 Created`

Returns the full product resource with nested variants, options, and media.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)

---

#### GET /api/admin/v1/stores/{storeId}/products/{productId}

Get a single product with all variants, options, media, and collections.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "
A comfortable cotton t-shirt.
",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors (invalid content type, file too large)

---

### 3.3 Collections

#### Admin Collections Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |


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

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

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

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

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