sed -n '1,260p' 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,
        ];
    }
}


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


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


sed -n '1,340p' app/Jobs/ProcessMediaUpload.php

ProcessMediaUpload.php

<?php

namespace App\Jobs;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    /**
     * @var array<string, array{0: int, 1: int}>
     */
    private const TARGETS = [
        'thumbnail' => [150, 150],
        'small' => [300, 300],
        'medium' => [600, 600],
        'large' => [1200, 1200],
    ];

    public function __construct(
        public int $productMediaId,
        public ?int $storeId = null,
    ) {}

    public function handle(): void
    {
        $media = $this->media();

        try {
            $this->process($media);
        } catch (Throwable $throwable) {
            $media->forceFill([
                'status' => MediaStatus::Failed,
            ])->save();

            Log::error('Media processing failed.', [
                'product_media_id' => $media->getKey(),
                'storage_key' => $media->storage_key,
                'exception' => $throwable->getMessage(),
            ]);

            throw $throwable;
        }
    }

    private function media(): ProductMedia
    {
        $query = ProductMedia::withoutGlobalScopes()->whereKey($this->productMediaId);

        if ($this->storeId !== null) {
            $query->whereHas('product', function (Builder $query): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $this->storeId);
            });
        }

        return $query->firstOrFail();
    }

    private function process(ProductMedia $media): void
    {
        $disk = Storage::disk('public');

        if (! $disk->exists($media->storage_key)) {
            throw new RuntimeException('Media file is missing from public storage.');
        }

        $contents = $disk->get($media->storage_key);
        $mimeType = $this->mimeType($contents);
        $byteSize = $disk->size($media->storage_key);

        $dimensions = match ($media->type) {
            MediaType::Image => $this->processImage($media, $contents, $mimeType),
            MediaType::Video => $this->validateVideo($mimeType),
        };

        $media->forceFill([
            'width' => $dimensions['width'],
            'height' => $dimensions['height'],
            'mime_type' => $mimeType,
            'byte_size' => $byteSize,
            'status' => MediaStatus::Ready,
        ])->save();
    }

    /**
     * @return array{width: int, height: int}
     */
    private function processImage(ProductMedia $media, string $contents, string $mimeType): array
    {
        if (! str_starts_with($mimeType, 'image/')) {
            throw new RuntimeException('Media file is not a supported image.');
        }

        $source = @imagecreatefromstring($contents);

        if ($source === false) {
            throw new RuntimeException('Media image could not be decoded.');
        }

        $sourceWidth = imagesx($source);
        $sourceHeight = imagesy($source);
        $extension = $this->extensionForMime($mimeType);
        $disk = Storage::disk('public');

        foreach (self::TARGETS as $size => [$maxWidth, $maxHeight]) {
            [$targetWidth, $targetHeight] = $this->fitDimensions($sourceWidth, $sourceHeight, $maxWidth, $maxHeight);
            $resized = imagecreatetruecolor($targetWidth, $targetHeight);

            imagealphablending($resized, false);
            imagesavealpha($resized, true);
            imagefilledrectangle(
                $resized,
                0,
                0,
                $targetWidth,
                $targetHeight,
                imagecolorallocatealpha($resized, 0, 0, 0, 127),
            );
            imagecopyresampled($resized, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);

            $basePath = "media/{$media->product_id}/{$media->getKey()}/{$size}";

            $disk->put("{$basePath}.{$extension}", $this->encodeImage($resized, $extension));

            if (function_exists('imagewebp')) {
                $disk->put("{$basePath}.webp", $this->encodeImage($resized, 'webp'));
            }

            imagedestroy($resized);
        }

        imagedestroy($source);

        return [
            'width' => $sourceWidth,
            'height' => $sourceHeight,
        ];
    }

    /**
     * @return array{width: null, height: null}
     */
    private function validateVideo(string $mimeType): array
    {
        if (! str_starts_with($mimeType, 'video/')) {
            throw new RuntimeException('Media file is not a supported video.');
        }

        return [
            'width' => null,
            'height' => null,
        ];
    }

    /**
     * @return array{0: int, 1: int}
     */
    private function fitDimensions(int $sourceWidth, int $sourceHeight, int $maxWidth, int $maxHeight): array
    {
        $ratio = min($maxWidth / $sourceWidth, $maxHeight / $sourceHeight, 1);

        return [
            max(1, (int) round($sourceWidth * $ratio)),
            max(1, (int) round($sourceHeight * $ratio)),
        ];
    }

    private function encodeImage(\GdImage $image, string $extension): string
    {
        ob_start();

        $encoded = match ($extension) {
            'jpg' => imagejpeg($image, null, 90),
            'png' => imagepng($image),
            'gif' => imagegif($image),
            'webp' => imagewebp($image, null, 90),
            default => false,
        };

        $contents = ob_get_clean();

        if ($encoded === false || $contents === false) {
            throw new RuntimeException('Unable to encode resized media image.');
        }

        return $contents;
    }

    private function mimeType(string $contents): string
    {
        $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);

        if (! is_string($mimeType) || $mimeType === '') {
            throw new RuntimeException('Unable to determine media MIME type.');
        }

        return $mimeType;
    }

    private function extensionForMime(string $mimeType): string
    {
        return match ($mimeType) {
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'image/webp' => 'webp',
            default => throw new RuntimeException("Unsupported image MIME type [{$mimeType}]."),
        };
    }
}


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


Output:
Chunk ID: 82ec09
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,
        ];
    }
}


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

namespace App\Jobs;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    /**
     * @var array<string, array{0: int, 1: int}>
     */
    private const TARGETS = [
        'thumbnail' => [150, 150],
        'small' => [300, 300],
        'medium' => [600, 600],
        'large' => [1200, 1200],
    ];

    public function __construct(
        public int $productMediaId,
        public ?int $storeId = null,
    ) {}

    public function handle(): void
    {
        $media = $this->media();

        try {
            $this->process($media);
        } catch (Throwable $throwable) {
            $media->forceFill([
                'status' => MediaStatus::Failed,
            ])->save();

            Log::error('Media processing failed.', [
                'product_media_id' => $media->getKey(),
                'storage_key' => $media->storage_key,
                'exception' => $throwable->getMessage(),
            ]);

            throw $throwable;
        }
    }

    private function media(): ProductMedia
    {
        $query = ProductMedia::withoutGlobalScopes()->whereKey($this->productMediaId);

        if ($this->storeId !== null) {
            $query->whereHas('product', function (Builder $query): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $this->storeId);
            });
        }

        return $query->firstOrFail();
    }

    private function process(ProductMedia $media): void
    {
        $disk = Storage::disk('public');

        if (! $disk->exists($media->storage_key)) {
            throw new RuntimeException('Media file is missing from public storage.');
        }

        $contents = $disk->get($media->storage_key);
        $mimeType = $this->mimeType($contents);
        $byteSize = $disk->size($media->storage_key);

        $dimensions = match ($media->type) {
            MediaType::Image => $this->processImage($media, $contents, $mimeType),
            MediaType::Video => $this->validateVideo($mimeType),
        };

        $media->forceFill([
            'width' => $dimensions['width'],
            'height' => $dimensions['height'],
            'mime_type' => $mimeType,
            'byte_size' => $byteSize,
            'status' => MediaStatus::Ready,
        ])->save();
    }

    /**
     * @return array{width: int, height: int}
     */
    private function processImage(ProductMedia $media, string $contents, string $mimeType): array
    {
        if (! str_starts_with($mimeType, 'image/')) {
            throw new RuntimeException('Media file is not a supported image.');
        }

        $source = @imagecreatefromstring($contents);

        if ($source === false) {
            throw new RuntimeException('Media image could not be decoded.');
        }

        $sourceWidth = imagesx($source);
        $sourceHeight = imagesy($source);
        $extension = $this->extensionForMime($mimeType);
        $disk = Storage::disk('public');

        foreach (self::TARGETS as $size => [$maxWidth, $maxHeight]) {
            [$targetWidth, $targetHeight] = $this->fitDimensions($sourceWidth, $sourceHeight, $maxWidth, $maxHeight);
            $resized = imagecreatetruecolor($targetWidth, $targetHeight);

            imagealphablending($resized, false);
            imagesavealpha($resized, true);
            imagefilledrectangle(
                $resized,
                0,
                0,
                $targetWidth,
                $targetHeight,
                imagecolorallocatealpha($resized, 0, 0, 0, 127),
            );
            imagecopyresampled($resized, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);

            $basePath = "media/{$media->product_id}/{$media->getKey()}/{$size}";

            $disk->put("{$basePath}.{$extension}", $this->encodeImage($resized, $extension));

            if (function_exists('imagewebp')) {
                $disk->put("{$basePath}.webp", $this->encodeImage($resized, 'webp'));
            }

            imagedestroy($resized);
        }

        imagedestroy($source);

        return [
            'width' => $sourceWidth,
            'height' => $sourceHeight,
        ];
    }

    /**
     * @return array{width: null, height: null}
     */
    private function validateVideo(string $mimeType): array
    {
        if (! str_starts_with($mimeType, 'video/')) {
            throw new RuntimeException('Media file is not a supported video.');
        }

        return [
            'width' => null,
            'height' => null,
        ];
    }

    /**
     * @return array{0: int, 1: int}
     */
    private function fitDimensions(int $sourceWidth, int $sourceHeight, int $maxWidth, int $maxHeight): array
    {
        $ratio = min($maxWidth / $sourceWidth, $maxHeight / $sourceHeight, 1);

        return [
            max(1, (int) round($sourceWidth * $ratio)),
            max(1, (int) round($sourceHeight * $ratio)),
        ];
    }

    private function encodeImage(\GdImage $image, string $extension): string
    {
        ob_start();

        $encoded = match ($extension) {
            'jpg' => imagejpeg($image, null, 90),
            'png' => imagepng($image),
            'gif' => imagegif($image),
            'webp' => imagewebp($image, null, 90),
            default => false,
        };

        $contents = ob_get_clean();

        if ($encoded === false || $contents === false) {
            throw new RuntimeException('Unable to encode resized media image.');
        }

        return $contents;
    }

    private function mimeType(string $contents): string
    {
        $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);

        if (! is_string($mimeType) || $mimeType === '') {
            throw new RuntimeException('Unable to determine media MIME type.');
        }

        return $mimeType;
    }

    private function extensionForMime(string $mimeType): string
    {
        return match ($mimeType) {
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/gif' => 'gif',
            'image/webp' => 'webp',
            default => throw new RuntimeException("Unsupported image MIME type [{$mimeType}]."),
        };
    }
}


The final review shows no whitespace errors and the expected files are dirty or untracked for this Phase 2 slice. I’m staging the catalog data-layer changes and committing them now.


git add app/Enums app/Events app/Exceptions app/Jobs app/Models app/Services app/Support database/factories database/migrations database/seeders tests/Feature/Catalog specs/progress.md && git commit -m "Build catalog data layer"


git add app/Enums app/Events app/Exceptions app/Jobs app/Models app/Services app/Support database/factories database/migrations database/seeders tests/Feature/Catalog specs/progress.md && git commit -m "Build catalog data layer"

[2026-05-04-codex-5-5-goal aee752ea] Build catalog data layer
 56 files changed, 3438 insertions(+), 54 deletions(-)
 create mode 100644 app/Enums/CollectionStatus.php
 create mode 100644 app/Enums/CollectionType.php
 create mode 100644 app/Enums/InventoryPolicy.php
 create mode 100644 app/Enums/MediaStatus.php
 create mode 100644 app/Enums/MediaType.php
 create mode 100644 app/Enums/ProductStatus.php
 create mode 100644 app/Enums/VariantStatus.php
 create mode 100644 app/Events/ProductStatusChanged.php
 create mode 100644 app/Exceptions/InsufficientInventoryException.php
 create mode 100644 app/Exceptions/InvalidProductTransitionException.php
 create mode 100644 app/Jobs/ProcessMediaUpload.php
 create mode 100644 app/Models/Collection.php
 create mode 100644 app/Models/InventoryItem.php
 create mode 100644 app/Models/Product.php
 create mode 100644 app/Models/ProductMedia.php
 create mode 100644 app/Models/ProductOption.php
 create mode 100644 app/Models/ProductOptionValue.php
 create mode 100644 app/Models/ProductVariant.php
 create mode 100644 app/Services/InventoryService.php
 create mode 100644 app/Services/ProductService.php
 create mode 100644 app/Services/VariantMatrixService.php
 create mode 100644 app/Support/HandleGenerator.php
 create mode 100644 database/factories/CollectionFactory.php
 create mode 100644 database/factories/InventoryItemFactory.php
 create mode 100644 database/factories/ProductFactory.php
 create mode 100644 database/factories/ProductMediaFactory.php
 create mode 100644 database/factories/ProductOptionFactory.php
 create mode 100644 database/factories/ProductOptionValueFactory.php
 create mode 100644 database/factories/ProductVariantFactory.php
 create mode 100644 database/migrations/2026_05_03_213856_create_products_table.php
 create mode 100644 database/migrations/2026_05_03_213857_create_product_options_table.php
 create mode 100644 database/migrations/2026_05_03_213858_create_product_option_values_table.php
 create mode 100644 database/migrations/2026_05_03_213859_create_product_variants_table.php
 create mode 100644 database/migrations/2026_05_03_213900_create_variant_option_values_table.php
 create mode 100644 database/migrations/2026_05_03_213901_create_inventory_items_table.php
 create mode 100644 database/migrations/2026_05_03_213902_create_collections_table.php
 create mode 100644 database/migrations/2026_05_03_213903_create_collection_products_table.php
 create mode 100644 database/migrations/2026_05_03_213904_create_product_media_table.php
 create mode 100644 database/seeders/CollectionSeeder.php
 create mode 100644 database/seeders/InventoryItemSeeder.php
 create mode 100644 database/seeders/ProductMediaSeeder.php
 create mode 100644 database/seeders/ProductOptionSeeder.php
 create mode 100644 database/seeders/ProductOptionValueSeeder.php
 create mode 100644 database/seeders/ProductSeeder.php
 create mode 100644 database/seeders/ProductVariantSeeder.php
 create mode 100644 tests/Feature/Catalog/CatalogFoundationTest.php
 create mode 100644 tests/Feature/Catalog/InventoryServiceTest.php
 create mode 100644 tests/Feature/Catalog/MediaProcessingTest.php
 create mode 100644 tests/Feature/Catalog/ProductServiceTest.php
 create mode 100644 tests/Feature/Catalog/VariantMatrixServiceTest.php


Output:
Chunk ID: 4762b5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 822
Output:
[2026-05-04-codex-5-5-goal aee752ea] Build catalog data layer
 56 files changed, 3438 insertions(+), 54 deletions(-)
 create mode 100644 app/Enums/CollectionStatus.php
 create mode 100644 app/Enums/CollectionType.php
 create mode 100644 app/Enums/InventoryPolicy.php
 create mode 100644 app/Enums/MediaStatus.php
 create mode 100644 app/Enums/MediaType.php
 create mode 100644 app/Enums/ProductStatus.php
 create mode 100644 app/Enums/VariantStatus.php
 create mode 100644 app/Events/ProductStatusChanged.php
 create mode 100644 app/Exceptions/InsufficientInventoryException.php
 create mode 100644 app/Exceptions/InvalidProductTransitionException.php
 create mode 100644 app/Jobs/ProcessMediaUpload.php
 create mode 100644 app/Models/Collection.php
 create mode 100644 app/Models/InventoryItem.php
 create mode 100644 app/Models/Product.php
 create mode 100644 app/Models/ProductMedia.php
 create mode 100644 app/Models/ProductOption.php
 create mode 100644 app/Models/ProductOptionValue.php
 create mode 100644 app/Models/ProductVariant.php
 create mode 100644 app/Services/InventoryService.php
 create mode 100644 app/Services/ProductService.php
 create mode 100644 app/Services/VariantMatrixService.php
 create mode 100644 app/Support/HandleGenerator.php
 create mode 100644 database/factories/CollectionFactory.php
 create mode 100644 database/factories/InventoryItemFactory.php
 create mode 100644 database/factories/ProductFactory.php
 create mode 100644 database/factories/ProductMediaFactory.php
 create mode 100644 database/factories/ProductOptionFactory.php
 create mode 100644 database/factories/ProductOptionValueFactory.php
 create mode 100644 database/factories/ProductVariantFactory.php
 create mode 100644 database/migrations/2026_05_03_213856_create_products_table.php
 create mode 100644 database/migrations/2026_05_03_213857_create_product_options_table.php
 create mode 100644 database/migrations/2026_05_03_213858_create_product_option_values_table.php
 create mode 100644 database/migrations/2026_05_03_213859_create_product_variants_table.php
 create mode 100644 database/migrations/2026_05_03_213900_create_variant_option_values_table.php
 create mode 100644 database/migrations/2026_05_03_213901_create_inventory_items_table.php
 create mode 100644 database/migrations/2026_05_03_213902_create_collections_table.php
 create mode 100644 database/migrations/2026_05_03_213903_create_collection_products_table.php
 create mode 100644 database/migrations/2026_05_03_213904_create_product_media_table.php
 create mode 100644 database/seeders/CollectionSeeder.php
 create mode 100644 database/seeders/InventoryItemSeeder.php
 create mode 100644 database/seeders/ProductMediaSeeder.php
 create mode 100644 database/seeders/ProductOptionSeeder.php
 create mode 100644 database/seeders/ProductOptionValueSeeder.php
 create mode 100644 database/seeders/ProductSeeder.php
 create mode 100644 database/seeders/ProductVariantSeeder.php
 create mode 100644 tests/Feature/Catalog/CatalogFoundationTest.php
 create mode 100644 tests/Feature/Catalog/InventoryServiceTest.php
 create mode 100644 tests/Feature/Catalog/MediaProcessingTest.php
 create mode 100644 tests/Feature/Catalog/ProductServiceTest.php
 create mode 100644 tests/Feature/Catalog/VariantMatrixServiceTest.php


aee752ea Build catalog data layer
f3615da8 Build shop tenancy foundation


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


Output:
Chunk ID: eb5afc
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 19
Output:
aee752ea Build catalog data layer
f3615da8 Build shop tenancy foundation


Patch catalog service/model invariants from high QA findings

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

Rebuild seed data assertions and progress evidence

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

Explanation: Phase 2 catalog data layer is committed as a clean slice.


Output: Plan updated


Phase 2 data layer is committed as `aee752ea Build catalog data layer`, and the worktree is clean. I’m moving into the remaining Phase 2 surface now: catalog routes and Livewire/Flux views for admin product/collection management plus storefront product/collection browsing.


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/livewire-development/SKILL.md


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/fluxui-development/SKILL.md


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/tailwindcss-development/SKILL.md


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/livewire-development/SKILL.md

---
name: livewire-development
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
license: MIT
metadata:
  author: laravel
---

# Livewire Development

## Documentation

Use `search-docs` for detailed Livewire 4 patterns and documentation.

## Basic Usage

### Creating Components

```bash

# Single-file component (SFC - default in v4)

# Creates: resources/views/components/⚡create-post.blade.php

php artisan make:livewire create-post

# Page component (SFC - Full Page in v4)

# Creates: resources/views/pages/⚡create-post.blade.php

php artisan make:livewire pages::create-post

# Multi-file component (MFC)

# Creates: resources/views/components/⚡create-post/create-post.php

#          resources/views/components/⚡create-post/create-post.blade.php

php artisan make:livewire create-post --mfc

# Class-based component (v3 style)

# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php

php artisan make:livewire create-post --class

# With namespace

php artisan make:livewire Posts/CreatePost
```

### Converting Between Formats

Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.

### Choosing a Component Format

> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.

Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.

### Component Format Reference

| Format | Flag | Class Path | View Path |
|--------|------|------------|-----------|
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |

> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.

Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.

### Single-File Component Example

<!-- Single-File Component Example -->
```php
<?php
use Livewire\Component;

new class extends Component {
    public int $count = 0;

    public function increment(): void
    {
        $this->count++;
    }
};
?>

<div>
    <button wire:click="increment">Count: @{{ $count }}</button>
</div>
```

## Livewire 4 Specifics

### Key Changes From Livewire 3

These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.

- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.

### New Features

- Component formats: single-file (SFC), multi-file (MFC), view-based components.
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.

| Feature | Usage | Purpose |
|---------|-------|---------|
| Islands | `@island(name: 'stats')` | Isolated update regions |
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
| Deferred | `defer` attribute | Load after page render |
| Bundled | `lazy.bundle` | Load multiple together |

### New Directives

- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
- `data-loading` attribute automatically added to elements triggering network requests.

| Directive | Purpose |
|-----------|---------|
| `wire:sort` | Drag-and-drop sorting |
| `wire:intersect` | Viewport intersection detection |
| `wire:ref` | Element references for JS |
| `.renderless` | Component without rendering |
| `.preserve-scroll` | Preserve scroll position |

## Best Practices

- Always use `wire:key` in loops
- Use `wire:loading` for loading states
- Use `wire:model.live` for instant updates (default is debounced)
- Validate and authorize in actions (treat like HTTP requests)

## Configuration

- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.

## Alpine & JavaScript

- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.

For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).

## Testing

<!-- Testing Example -->
```php
Livewire::test(Counter::class)
    ->assertSet('count', 0)
    ->call('increment')
    ->assertSet('count', 1);
```

## Verification

1. Browser console: Check for JS errors
2. Network tab: Verify Livewire requests return 200
3. Ensure `wire:key` on all `@foreach` loops

## Common Pitfalls

- Missing `wire:key` in loops → unexpected re-rendering
- Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4)


Output:
Chunk ID: caaf2a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1888
Output:
---
name: livewire-development
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
license: MIT
metadata:
  author: laravel
---

# Livewire Development

## Documentation

Use `search-docs` for detailed Livewire 4 patterns and documentation.

## Basic Usage

### Creating Components

```bash

# Single-file component (SFC - default in v4)

# Creates: resources/views/components/⚡create-post.blade.php

php artisan make:livewire create-post

# Page component (SFC - Full Page in v4)

# Creates: resources/views/pages/⚡create-post.blade.php

php artisan make:livewire pages::create-post

# Multi-file component (MFC)

# Creates: resources/views/components/⚡create-post/create-post.php

#          resources/views/components/⚡create-post/create-post.blade.php

php artisan make:livewire create-post --mfc

# Class-based component (v3 style)

# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php

php artisan make:livewire create-post --class

# With namespace

php artisan make:livewire Posts/CreatePost
```

### Converting Between Formats

Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.

### Choosing a Component Format

> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.

Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.

### Component Format Reference

| Format | Flag | Class Path | View Path |
|--------|------|------------|-----------|
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |

> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.

Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.

### Single-File Component Example

<!-- Single-File Component Example -->
```php
<?php
use Livewire\Component;

new class extends Component {
    public int $count = 0;

    public function increment(): void
    {
        $this->count++;
    }
};
?>

<div>
    <button wire:click="increment">Count: @{{ $count }}</button>
</div>
```

## Livewire 4 Specifics

### Key Changes From Livewire 3

These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.

- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.

### New Features

- Component formats: single-file (SFC), multi-file (MFC), view-based components.
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.

| Feature | Usage | Purpose |
|---------|-------|---------|
| Islands | `@island(name: 'stats')` | Isolated update regions |
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
| Deferred | `defer` attribute | Load after page render |
| Bundled | `lazy.bundle` | Load multiple together |

### New Directives

- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
- `data-loading` attribute automatically added to elements triggering network requests.

| Directive | Purpose |
|-----------|---------|
| `wire:sort` | Drag-and-drop sorting |
| `wire:intersect` | Viewport intersection detection |
| `wire:ref` | Element references for JS |
| `.renderless` | Component without rendering |
| `.preserve-scroll` | Preserve scroll position |

## Best Practices

- Always use `wire:key` in loops
- Use `wire:loading` for loading states
- Use `wire:model.live` for instant updates (default is debounced)
- Validate and authorize in actions (treat like HTTP requests)

## Configuration

- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.

## Alpine & JavaScript

- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.

For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).

## Testing

<!-- Testing Example -->
```php
Livewire::test(Counter::class)
    ->assertSet('count', 0)
    ->call('increment')
    ->assertSet('count', 1);
```

## Verification

1. Browser console: Check for JS errors
2. Network tab: Verify Livewire requests return 200
3. Ensure `wire:key` on all `@foreach` loops

## Common Pitfalls

- Missing `wire:key` in loops → unexpected re-rendering
- Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4)


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/fluxui-development/SKILL.md

---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
  author: laravel
---

# Flux UI Development

## Documentation

Use `search-docs` for detailed Flux UI patterns and documentation.

## Basic Usage

This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.

Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.

Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.

<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```

## Available Components (Free Edition)

Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip

## Icons

Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.

<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```

For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:

```bash
php artisan flux:icon crown grip-vertical github
```

## Common Patterns

### Form Fields

<!-- Form Field -->
```blade
<flux:field>
    <flux:label>Email</flux:label>
    <flux:input type="email" wire:model="email" />
    <flux:error name="email" />
</flux:field>
```

### Modals

<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
    <flux:heading>Title</flux:heading>
    <p>Content</p>
</flux:modal>
```

## Verification

1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness

## Common Pitfalls

- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage


sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/tailwindcss-development/SKILL.md

---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
  author: laravel
---

# Tailwind CSS Development

## Documentation

Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.

## Basic Usage

- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.

## Tailwind CSS v4 Specifics

- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.

### CSS-First Configuration

In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:

<!-- CSS-First Config -->
```css
@theme {
  --color-brand: oklch(0.72 0.11 178);
}
```

### Import Syntax

In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:

<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```

### Replaced Utilities

Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.

| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |

## Spacing

Use `gap` utilities instead of margins for spacing between siblings:

<!-- Gap Utilities -->
```html
<div class="flex gap-8">
    <div>Item 1</div>
    <div>Item 2</div>
</div>
```

## Dark Mode

If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:

<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
    Content adapts to color scheme
</div>
```

## Common Patterns

### Flexbox Layout

<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
    <div>Left content</div>
    <div>Right content</div>
</div>
```

### Grid Layout

<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    <div>Card 1</div>
    <div>Card 2</div>
    <div>Card 3</div>
</div>
```

## Common Pitfalls

- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode


Output:
Chunk ID: 041e5f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 675
Output:
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
  author: laravel
---

# Flux UI Development

## Documentation

Use `search-docs` for detailed Flux UI patterns and documentation.

## Basic Usage

This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.

Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.

Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.

<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```

## Available Components (Free Edition)

Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip

## Icons

Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.

<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```

For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:

```bash
php artisan flux:icon crown grip-vertical github
```

## Common Patterns

### Form Fields

<!-- Form Field -->
```blade
<flux:field>
    <flux:label>Email</flux:label>
    <flux:input type="email" wire:model="email" />
    <flux:error name="email" />
</flux:field>
```

### Modals

<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
    <flux:heading>Title</flux:heading>
    <p>Content</p>
</flux:modal>
```

## Verification

1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness

## Common Pitfalls

- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage


Output:
Chunk ID: 6f980f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 962
Output:
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
  author: laravel
---

# Tailwind CSS Development

## Documentation

Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.

## Basic Usage

- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.

## Tailwind CSS v4 Specifics

- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.

### CSS-First Configuration

In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:

<!-- CSS-First Config -->
```css
@theme {
  --color-brand: oklch(0.72 0.11 178);
}
```

### Import Syntax

In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:

<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```

### Replaced Utilities

Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.

| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |

## Spacing

Use `gap` utilities instead of margins for spacing between siblings:

<!-- Gap Utilities -->
```html
<div class="flex gap-8">
    <div>Item 1</div>
    <div>Item 2</div>
</div>
```

## Dark Mode

If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:

<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
    Content adapts to color scheme
</div>
```

## Common Patterns

### Flexbox Layout

<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
    <div>Left content</div>
    <div>Right content</div>
</div>
```

### Grid Layout

<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    <div>Card 1</div>
    <div>Card 2</div>
    <div>Card 3</div>
</div>
```

## Common Pitfalls

- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode


livewire/livewire livewire/flux laravel/framework pestphp/pest

full page component route livewire form validation pagination

flux form input select textarea button badge modal

livewire testing component form actions pagination

blade route model binding slug scoped route


# Search Results

## Redirecting Redirecting to full-page components
Redirecting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/redirecting

## Redirecting to full-page components

Because Livewire uses Laravel's built-in redirection feature, you can use all of the redirection methods available to you in a typical Laravel application.

For example, if you are using a Livewire component as a full-page component for a route like so:

```php
Route::livewire('/posts', 'pages::show-posts');
```

You can redirect to it simply by using the route path:

```php
public function save()
{
    // ...

    $this->redirect('/posts');
}
```

---

## Routing Route Model Binding Implicit Binding Custom Keys and Scoping
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

#### Custom Keys and Scoping

When implicitly binding multiple Eloquent models in a single route definition, you may wish to scope the second Eloquent model such that it must be a child of the previous Eloquent model. For example, consider this route definition that retrieves a blog post by slug for a specific user:

```php
use App\Models\Post;
use App\Models\User;

Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
    return $post;
});
```

When using a custom keyed implicit binding as a nested route parameter, Laravel will automatically scope the query to retrieve the nested model by its parent using conventions to guess the relationship name on the parent. In this case, it will be assumed that the `User` model has a relationship named `posts` (the plural form of the route parameter name) which can be used to retrieve the `Post` model.

If you wish, you may instruct Laravel to scope "child" bindings even when a custom key is not provided. To do so, you may invoke the `scopeBindings` method when defining your route:

```php
use App\Models\Post;
use App\Models\User;

Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
    return $post;
})->scopeBindings();
```

Or, you may instruct an entire group of route definitions to use scoped bindings:

```php
Route::scopeBindings()->group(function () {
    Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
        return $post;
    });
});
```

Similarly, you may explicitly instruct Laravel to not scope bindings by invoking the `withoutScopedBindings` method:

```php
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
    return $post;
})->withoutScopedBindings();
```

---

## Components Input > With buttons
Components Input | livewire/flux@2.x | Source: https://fluxui.dev/components/input#livewire/flux

With buttons

Attach buttons to the beginning or end of an input element.

<flux:input.group>    <flux:input placeholder="Post title" />    <flux:button icon="plus">New post</flux:button></flux:input.group><flux:input.group>    <flux:select class="max-w-fit">        <flux:select.option selected>USD</flux:select.option>            </flux:select>    <flux:input placeholder="$99.99" /></flux:input.group>

---

## Http Tests Validation Assertions assertInvalid
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### assertInvalid

Assert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:

```php
$response->assertInvalid(['name', 'email']);
```

You may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:

```php
$response->assertInvalid([
    'name' => 'The name field is required.',
    'email' => 'valid email address',
]);
```

---

## Lazy Full-page lazy loading
Lazy | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lazy

## Full-page lazy loading

You can lazy load or defer full-page Livewire components using route methods.

### Lazy loading full pages

Use `->lazy()` to load the component when it enters the viewport:

```php
Route::livewire('/dashboard', 'pages::dashboard')->lazy();
```

### Deferring full pages

Use `->defer()` to load the component immediately after the page loads:

```php
Route::livewire('/dashboard', 'pages::dashboard')->defer();
```

### Disabling lazy/defer loading

If a component is lazy or deferred by default (via the `#[Lazy]` or `#[Defer]` attribute), you can opt-out using `enabled: false`:

```php
Route::livewire('/dashboard', 'pages::dashboard')->lazy(enabled: false);
Route::livewire('/dashboard', 'pages::dashboard')->defer(enabled: false);
```

---

## Routing Route Model Binding
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

## Route Model Binding

When injecting a model ID to a route or controller action, you will often query the database to retrieve the model that corresponds to that ID. Laravel route model binding provides a convenient way to automatically inject the model instances directly into your routes. For example, instead of injecting a user's ID, you can inject the entire `User` model instance that matches the given ID.

---

## Components Field > With badge
Components Field | livewire/flux@2.x | Source: https://fluxui.dev/components/field#livewire/flux

With badge

Badges allow you to enhance a field with additional information such as being "required" or "optional" when it might not be expected.

<flux:field>    <flux:label badge="Required">Email</flux:label>    <flux:input type="email" required />    <flux:error name="email" /></flux:field><flux:field>    <flux:label badge="Optional">Phone number</flux:label>    <flux:input type="phone" placeholder="(555) 555-5555" mask="(999) 999-9999"  />    <flux:error name="phone" /></flux:field>

---

## Routing Route Model Binding Implicit Binding Customizing the Key
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

#### Customizing the Key

Sometimes you may wish to resolve Eloquent models using a column other than `id`. To do so, you may specify the column in the route parameter definition:

```php
use App\Models\Post;

Route::get('/posts/{post:slug}', function (Post $post) {
    return $post;
});
```

If you would like model binding to always use a database column other than `id` when retrieving a given model class, you may override the `getRouteKeyName` method on the Eloquent model:

```php
/**
 * Get the route key for the model.
 */
public function getRouteKeyName(): string
{
    return 'slug';
}
```

---

## Components Input > Icon buttons
Components Input | livewire/flux@2.x | Source: https://fluxui.dev/components/input#livewire/flux

Icon buttons

Append a button to the inside of an input to provide associated functionality.

<flux:input placeholder="Search orders">    <x-slot name="iconTrailing">        <flux:button size="sm" variant="subtle" icon="x-mark" class="-mr-1" />    </x-slot></flux:input><flux:input type="password" value="password">    <x-slot name="iconTrailing">        <flux:button size="sm" variant="subtle" icon="eye" class="-mr-1" />    </x-slot></flux:input>

---

## Testing See also
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## See also

- **[Actions](/docs/4.x/actions)** — Test component actions and interactions
- **[Forms](/docs/4.x/forms)** — Test form submissions and validation
- **[Events](/docs/4.x/events)** — Test event dispatching and listening
- **[Components](/docs/4.x/components)** — Create testable component structure

---

## Test Impact Analysis
Test Impact Analysis | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/test-impact-analysis.md

---
title: Test Impact Analysis (TIA)
description: Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes.
---

# Test Impact Analysis (TIA)

Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes. The first time you run with `--tia`, Pest records a graph of which tests depend on which files. Every run after that, Pest looks at what you changed, runs only the tests that touched those files, and replays cached results for everything else.

A typical Laravel suite that takes 15 seconds replays in under a second. Edits to a single Blade template re-run a handful of feature tests. Comment-only edits, formatter passes, and README touches re-run nothing at all.

To get started, just add the `--tia` flag to any Pest invocation:

```bash
./vendor/bin/pest --parallel --tia
```

The first run is the **baseline** — Pest enables a coverage driver (PCOV or Xdebug) and records the dependency graph as your tests execute. Expect a small overhead on this run only.

Every subsequent run is a **replay**. Pest compares your working tree against the baseline and re-runs only the tests affected by your changes:

```
Tests:    774 passed (2658 assertions, 7 affected, 767 replayed)
Duration: 0.74s
```

`affected` is the set of tests Pest actually executed. `replayed` is the set whose results were served from cache.

## How Pest Decides What To Run

For each file you've changed, Pest looks for tests that depend on it:

- **PHP source files** — your `app/` classes, controllers, models, helpers — are tracked through the coverage driver. A change to `app/Models/User.php` re-runs only the tests that touched `User`.
- **Migrations** are intersected with the tables each test queried during the baseline. A column rename in `create_users_table.php` re-runs only the tests that queried the `users` table.
- **Inertia pages** under `resources/js/Pages` re-run only the tests that server-side-rendered them.
- **Shared JS components** under `resources/js/Components`, `Layouts`, and friends re-run only the tests whose pages import them — Pest walks Vite's module graph to figure this out.
- **Blade templates** re-run only the tests that rendered them, including renders triggered by browser tests.
- **Anything else** — config files, route files, `.env`, files outside the recorded graph — falls through to a broader pattern. Editing `config/app.php` re-runs every test, because Pest can't statically prove which tests depend on it.

## Cosmetic Edits Don't Run Anything

Pest normalises file content before comparing, so cosmetic changes don't trigger any tests. PHP files have whitespace, line comments, and docblocks stripped before hashing. Blade strips `{{-- … --}}` comments. JS, TS, Vue, and Svelte files have line and block comments removed too.

The result: a comment-only edit, a Prettier reformat, a Pint pass, or a README tweak produces an identical hash, and the file never enters the changed set. Zero tests run.

## Modes

Pest supports a few flags alongside `--tia`:

| Flag | Behaviour |
|---|---|
| `--tia` | Replay if a baseline graph exists, otherwise record. |
| `--tia --fresh` | Discard any existing graph and re-record from scratch. Use this after large refactors or when the graph feels stale. |
| `--tia --refetch` | Force a CI baseline fetch even within the 24-hour cooldown after a previous failed fetch.

## Sharing The Baseline From CI

Recording the baseline locally takes minutes on large suites. Instead, you can have CI record it once per merge to `main`, and every developer downloads the result.

When Pest detects no local graph (or the local graph is out of date), it uses GitHub's CLI to download the latest successful run of a `tia-baseline.yml` workflow's `pest-tia-baseline` artifact. Pest validates the fetched graph against your project state — if it matches, it's adopted. Otherwise it's discarded and a local rebuild proceeds.

Here's a starter workflow you can drop into `.github/workflows/tia-baseline.yml`:

```yaml
name: TIA Baseline
on:
  push: { branches: [main] }
  schedule: [{ cron: '0 3 * * *' }]
  workflow_dispatch:
jobs:
  baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.5', coverage: xdebug }
      - run: composer install --no-interaction --prefer-dist
      - run: ./vendor/bin/pest --parallel --tia --coverage
      - name: Stage baseline for upload
        shell: bash
        run: |
          mkdir -p .pest-tia-baseline
          cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
      - uses: actions/upload-artifact@v4
        with:
          name: pest-tia-baseline
          path: .pest-tia-baseline/
          retention-days: 30
```

After CI runs, every developer who runs `pest --tia` for the first time on the repo will download this baseline and start replaying immediately, paying no record cost.

## Storage

Pest stores its state at `~/.pest/tia/<project-key>/`, where the project key is derived from your normalised git remote URL — so `git@github.com:foo/bar.git` and `https://github.com/foo/bar` produce the same key. A non-git project falls back to a hash of the project's absolute path.

Sharing state per remote URL means multiple worktrees of the same repository share one cache, while unrelated projects on the same machine stay isolated.

## Custom Watch Patterns

If your project has a directory layout that doesn't match the framework defaults, you can register custom watch patterns in `tests/Pest.php`:

```php
pest()->tia()->watch([
    'config/billing/**/*.php' => ['tests/Feature/Billing'],

    //
]);
```

Each glob maps to a list of test directories; whenever a matching file changes, every test under those directories is invalidated. Use this when you have specific test directories that genuinely depend on a file Pest can't see otherwise.

---

Now that you've learned how to use Test Impact Analysis to speed up your test suite, let's discuss how to integrate Pest with your continuous integration workflow: [Continuous Integration](/docs/continuous-integration)

---

## Attribute Title Only for full-page components
Attribute Title | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-title

## Only for full-page components

> [!info] Full-page components only
> The `#[Title]` attribute only works for full-page components accessed via routes. Regular components rendered within other views don't use titles—they inherit the parent page's title.

---

## Components Badge > With close button
Components Badge | livewire/flux@2.x | Source: https://fluxui.dev/components/badge#livewire/flux

With close button

Make a badge removable by appending a close button.

<flux:badge>    Admin <flux:badge.close /></flux:badge>

---

## Components Modal > Modal
Components Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux

    Modal

Display content in a layer above the main page. Ideal for confirmations, alerts, and forms.

<flux:modal.trigger name="edit-profile">    <flux:button>Edit profile</flux:button></flux:modal.trigger><flux:modal name="edit-profile" class="md:w-96">

            <flux:heading size="lg">Update profile</flux:heading>            <flux:text class="mt-2">Make changes to your personal details.</flux:text>
        <flux:input label="Name" placeholder="Your name" />        <flux:input label="Date of birth" type="date" />
            <flux:spacer />            <flux:button type="submit" variant="primary">Save changes</flux:button>

</flux:modal>

---

## Filtering Tests `only()`
Filtering Tests | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/filtering-tests.md

### `only()`

During development, you may want to focus on running specific tests while excluding all others. Pest provides two ways to do this: running only tests in a specific file, or running only a specific test within a file.

#### Running Only Tests in a File

When working on a specific feature, you can mark all tests in a file to run exclusively by calling the `pest()->only()` function at the top of your test file:

```php
<?php

pest()->only();

test('first test', function () {
    // This will run
});

test('second test', function () {
    // This will also run
});
```

All tests in files with `pest()->only()` will run, while tests in other files will be skipped.

#### Running a Single Test

To run only a specific test within a file, chain the `->only()` method to the test:

```php
test('sum', function () {
  $result = sum(1, 2);

  expect($result)->toBe(3);
})->only();

test('another test', function () {
  // This will be skipped
});
```

---

As your codebase grows, manually running your tests with filtering can become tedious. That's where skipping tests comes in. Skipping tests is a useful feature that allows developers to exclude specific tests from the test suite temporarily without deleting them entirely: [Skipping Tests →](/docs/skipping-tests)

---

## Components Button > Full width
Components Button | livewire/flux@2.x | Source: https://fluxui.dev/components/button#livewire/flux

Full width

A button that spans the full width of the container.

<flux:button variant="primary" class="w-full">Send invite</flux:button>

---

## Routing Route Model Binding Explicit Binding
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

### Explicit Binding

You are not required to use Laravel's implicit, convention based model resolution in order to use model binding. You can also explicitly define how route parameters correspond to models. To register an explicit binding, use the router's `model` method to specify the class for a given parameter. You should define your explicit model bindings at the beginning of the `boot` method of your `AppServiceProvider` class:

```php
use App\Models\User;
use Illuminate\Support\Facades\Route;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Route::model('user', User::class);
}
```

Next, define a route that contains a `{user}` parameter:

```php
use App\Models\User;

Route::get('/users/{user}', function (User $user) {
    // ...
});
```

Since we have bound all `{user}` parameters to the `App\Models\User` model, an instance of that class will be injected into the route. So, for example, a request to `users/1` will inject the `User` instance from the database which has an ID of `1`.

If a matching model instance is not found in the database, a 404 HTTP response will be automatically generated.

---

## Routing Route Model Binding Implicit Binding Customizing Missing Model Behavior
Routing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/routing.md

#### Customizing Missing Model Behavior

Typically, a 404 HTTP response will be generated if an implicitly bound model is not found. However, you may customize this behavior by calling the `missing` method when defining your route. The `missing` method accepts a closure that will be invoked if an implicitly bound model cannot be found:

```php
use App\Http\Controllers\LocationsController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;

Route::get('/locations/{location:slug}', [LocationsController::class, 'show'])
    ->name('locations.view')
    ->missing(function (Request $request) {
        return Redirect::route('locations.index');
    });
```

---

## Search Full-Text Search Running Full-Text Queries
Search | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md

### Running Full-Text Queries

Once the index is in place, use the `whereFullText` query builder method to search against it. Laravel will generate the appropriate SQL for your database driver — for example, `MATCH(...) AGAINST(...)` on MariaDB and MySQL, and `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL:

```php
$articles = Article::whereFullText('body', 'web developer')->get();
```

When using MariaDB and MySQL, results are automatically ordered by relevance score. On PostgreSQL, `whereFullText` filters matching records but does not order them by relevance — if you need automatic relevance ordering on PostgreSQL, consider using [Scout's database engine](#database-engine), which handles this for you.

If you created a composite full-text index across multiple columns, you may search against all of them by passing the same array of columns to `whereFullText`:

```php
$articles = Article::whereFullText(
    ['title', 'body'], 'web developer'
)->get();
```

The `orWhereFullText` method may be used to add a full-text search clause as an "or" condition. For complete details, consult the [query builder documentation](/docs/{{version}}/queries#full-text-where-clauses).

---

## Arch Testing Modifiers `abstracts()`
Arch Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/arch-testing.md

### `abstracts()`

The `abstracts()` modifier allows you to restrict the expectation to only abstract classes.

```php
arch('app')
    ->expect('App')
    ->abstracts()
    ->toImplement(JsonSerializable::class);
```
---

In this section, you have learned how to perform architectural testing, ensuring that your application or library's architecture meets a specified set of architectural requirements. Next, have you ever wondered how to test the performance of your code? Let's explore [Stress Testing](/docs/stress-testing).

---

## Testing Calling actions
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## Calling actions

Use the `call()` method to trigger component actions in your tests:

```php
use App\Models\Post;

it('can create a post', function () {
    expect(Post::count())->toBe(0);

    Livewire::test('post.create')
        ->set('title', 'My new post')
        ->set('content', 'Post content here')
        ->call('save');

    expect(Post::count())->toBe(1);
});
```

> [!tip] Pest expectations
> The examples above use Pest's `expect()` syntax for assertions. For a complete list of available expectations, see the [Pest expectations documentation](https://pestphp.com/docs/expectations).

You can pass parameters to actions:

```php
Livewire::test('post.show')
    ->call('deletePost', $postId);
```

### Testing validation

Assert that validation errors have been thrown using `assertHasErrors()`:

```php
it('title field is required', function () {
    Livewire::test('post.create')
        ->set('title', '')
        ->call('save')
        ->assertHasErrors('title');
});
```

Test specific validation rules:

```php
it('title must be at least 3 characters', function () {
    Livewire::test('post.create')
        ->set('title', 'ab')
        ->call('save')
        ->assertHasErrors(['title' => ['min:3']]);
});
```

### Testing authorization

Ensure authorization checks work correctly using `assertUnauthorized()` and `assertForbidden()`:

```php
use App\Models\User;
use App\Models\Post;

it('cannot update another users post', function () {
    $user = User::factory()->create();
    $stranger = User::factory()->create();
    $post = Post::factory()->for($stranger)->create();

    Livewire::actingAs($user)
        ->test('post.edit', ['post' => $post])
        ->set('title', 'Hacked!')
        ->call('save')
        ->assertForbidden();
});
```

### Testing redirects

Assert that an action performed a redirect:

```php
it('redirects to posts index after creating', function () {
    Livewire::test('post.create')
        ->set('title', 'New post')
        ->set('content', 'Content here')
        ->call('save')
        ->assertRedirect('/posts');
});
```

You can also assert redirects to named routes or page components:

```php
->assertRedirect(route('posts.index'));
->assertRedirectToRoute('posts.index');
```

### Testing events

Assert that events were dispatched from your component:

```php
it('dispatches event when post is created', function () {
    Livewire::test('post.create')
        ->set('title', 'New post')
        ->call('save')
        ->assertDispatched('post-created');
});
```

Test event communication between components:

```php
it('updates post count when event is dispatched', function () {
    $badge = Livewire::test('post-count-badge')
        ->assertSee('0');

    Livewire::test('post.create')
        ->set('title', 'New post')
        ->call('save')
        ->assertDispatched('post-created');

    $badge->dispatch('post-created')
        ->assertSee('1');
});
```

Assert events were dispatched with specific parameters:

```php
it('dispatches notification when deleting post', function () {
    Livewire::test('post.show')
        ->call('delete', postId: 3)
        ->assertDispatched('notify', message: 'Post deleted');
});
```

For complex assertions, use a closure:

```php
it('dispatches event with correct data', function () {
    Livewire::test('post.show')
        ->call('delete', postId: 3)
        ->assertDispatched('notify', function ($event, $params) {
            return ($params['message'] ?? '') === 'Post deleted';
        });
});
```

### Testing JS evaluation

Assert that a component evaluated JavaScript via `$this->js()`:

```php
it('shows an alert after saving', function () {
    Livewire::test('post.create')
        ->set('title', 'New post')
        ->call('save')
        ->assertJs("alert('Post saved!')");
});
```

You can also assert that no JS was evaluated:

```php
->assertNoJs();
```

---

## Browser Testing submit
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### submit

The `submit` method submits the first form found on the page:

```php
$page->submit();
```

---

## Search Full-Text Search Adding Full-Text Indexes
Search | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md

### Adding Full-Text Indexes

To use full-text search, first add a full-text index to the columns you want to search. You may add the index to a single column, or pass an array of columns to create a composite index that searches across multiple fields at once:

```php
Schema::create('articles', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('body');
    $table->timestamps();

    $table->fullText(['title', 'body']);
});
```

On PostgreSQL, you may specify a language configuration for the index, which controls how words are stemmed:

```php
$table->fullText('body')->language('english');
```

For more information on creating indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-index-types).

---

## Installation Next Steps Laravel the Full Stack Framework
Installation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/installation.md

### Laravel the Full Stack Framework

Laravel may serve as a full stack framework. By "full stack" framework we mean that you are going to use Laravel to route requests to your application and render your frontend via [Blade templates](/docs/{{version}}/blade) or a single-page application hybrid technology like [Inertia](https://inertiajs.com). This is the most common way to use the Laravel framework, and, in our opinion, the most productive way to use Laravel.

If this is how you plan to use Laravel, you may want to check out our documentation on [frontend development](/docs/{{version}}/frontend), [routing](/docs/{{version}}/routing), [views](/docs/{{version}}/views), or the [Eloquent ORM](/docs/{{version}}/eloquent). In addition, you might be interested in learning about community packages like [Livewire](https://livewire.laravel.com) and [Inertia](https://inertiajs.com). These packages allow you to use Laravel as a full-stack framework while enjoying many of the UI benefits provided by single-page JavaScript applications.

If you are using Laravel as a full stack framework, we also strongly encourage you to learn how to compile your application's CSS and JavaScript using [Vite](/docs/{{version}}/vite).

> [!NOTE]
> If you want to get a head start building your application, check out one of our official [application starter kits](/docs/{{version}}/starter-kits).

---

## Validation Testing validation
Validation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation

## Testing validation

Livewire provides useful testing utilities for validation scenarios, such as the `assertHasErrors()` method.

Below is a basic test case that ensures validation errors are thrown if no input is set for the `title` property:

```php
<?php

namespace Tests\Feature\Livewire;

use App\Livewire\CreatePost;
use Livewire\Livewire;
use Tests\TestCase;

class CreatePostTest extends TestCase
{
    public function test_cant_create_post_without_title()
    {
        Livewire::test(CreatePost::class)
            ->set('content', 'Sample content...')
            ->call('save')
            ->assertHasErrors('title');
    }
}
```

In addition to testing the presence of errors, `assertHasErrors` allows you to also narrow down the assertion to specific rules by passing the rules to assert against as the second argument to the method:

```php
public function test_cant_create_post_with_title_shorter_than_3_characters()
{
    Livewire::test(CreatePost::class)
        ->set('title', 'Sa')
        ->set('content', 'Sample content...')
        ->call('save')
        ->assertHasErrors(['title' => ['min:3']]);
}
```

You can also assert the presence of validation errors for multiple properties at the same time:

```php
public function test_cant_create_post_without_title_and_content()
{
    Livewire::test(CreatePost::class)
        ->call('save')
        ->assertHasErrors(['title', 'content']);
}
```

For more information on other testing utilities provided by Livewire, check out the [testing documentation](/docs/4.x/testing).

_[Truncated]_