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

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
use App\Livewire\Storefront\Cart\Show as CartShow;
use App\Livewire\Storefront\Checkout\Show as CheckoutShow;
use App\Livewire\Storefront\Products\Show as ProductShow;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function storefrontUiStore(): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $store);

    return $store;
}

function storefrontUiVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    return ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();
}

test('product detail add to cart persists a session cart', function () {
    $store = storefrontUiStore();
    $variant = storefrontUiVariant($store);

    Livewire::test(ProductShow::class, ['handle' => 'classic-cotton-t-shirt'])
        ->set('quantity', 2)
        ->call('addToCart')
        ->assertDispatched('cart-updated');

    $cart = Cart::withoutGlobalScopes()->findOrFail(session('cart_id'));

    expect($cart->store_id)->toBe($store->getKey())
        ->and($cart->lines()->withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity)->toBe(2);
});

test('cart page updates and removes line quantities', function () {
    $store = storefrontUiStore();
    $variant = storefrontUiVariant($store);
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    $line = app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    Livewire::test(CartShow::class)
        ->assertSee('Classic Cotton T-Shirt')
        ->call('increaseQuantity', $line->getKey())
        ->assertDispatched('cart-updated');

    expect($line->refresh()->quantity)->toBe(3);

    Livewire::test(CartShow::class)
        ->call('removeLine', $line->getKey())
        ->assertDispatched('cart-updated');

    expect($cart->lines()->withoutGlobalScopes()->count())->toBe(0);
});

test('cart page applies discount estimates shipping and carries discount into checkout', function () {
    $store = storefrontUiStore();
    $variant = storefrontUiVariant($store);
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $component = Livewire::test(CartShow::class)
        ->set('discountCode', 'SAVE10')
        ->call('applyDiscount')
        ->assertHasNoErrors()
        ->assertSet('appliedDiscountCode', 'SAVE10')
        ->set('shippingCountry', 'DE')
        ->set('shippingPostalCode', '10115')
        ->call('estimateShipping')
        ->assertHasNoErrors()
        ->assertSee('Standard Shipping');

    expect(session('cart_discount_code'))->toBe('SAVE10')
        ->and($component->instance()->discountAmount())->toBe(500)
        ->and($component->instance()->estimatedShippingAmount())->toBe(799)
        ->and($component->instance()->estimatedTotal())->toBe(5297);

    Livewire::test(CheckoutShow::class)
        ->assertSet('discountCode', 'SAVE10');

    $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail();

    expect($checkout->discount_code)->toBe('SAVE10')
        ->and($checkout->totals_json['discount'])->toBe(500);
});

test('customer login merges an existing guest cart without creating empty carts', function () {
    $store = storefrontUiStore();
    $variant = storefrontUiVariant($store);
    $customer = Customer::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('email', 'customer@acme.test')
        ->firstOrFail();
    $customerCart = app(CartService::class)->create($store, $customer);
    $guestCart = app(CartService::class)->create($store);
    session(['cart_id' => $guestCart->getKey()]);

    app(CartService::class)->addLine($customerCart, $variant->getKey(), 1);
    app(CartService::class)->addLine($guestCart, $variant->getKey(), 2);

    Livewire::test(CustomerLogin::class)
        ->set('email', 'customer@acme.test')
        ->set('password', 'password')
        ->call('login');

    expect($guestCart->refresh()->status)->toBe(CartStatus::Abandoned)
        ->and($customerCart->refresh()->lines()->withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity)->toBe(2)
        ->and(Cart::withoutGlobalScopes()->where('store_id', $store->getKey())->count())->toBe(2);
});

test('checkout page progresses through address shipping discount and payment selection', function () {
    $store = storefrontUiStore();
    $variant = storefrontUiVariant($store);
    $cart = app(CartService::class)->create($store);
    session(['cart_id' => $cart->getKey()]);
    app(CartService::class)->addLine($cart, $variant->getKey(), 2);

    $rate = ShippingRate::withoutGlobalScopes()
        ->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $store->getKey()))
        ->where('name', 'Standard Shipping')
        ->firstOrFail();

    Livewire::test(CheckoutShow::class)
        ->set('email', 'buyer@example.test')
        ->set('shippingAddress.first_name', 'Test')
        ->set('shippingAddress.last_name', 'Buyer')
        ->set('shippingAddress.address1', 'Main Street 1')
        ->set('shippingAddress.city', 'Berlin')
        ->set('shippingAddress.country', 'DE')
        ->set('shippingAddress.postal_code', '10115')
        ->call('saveAddress')
        ->assertSet('step', 'shipping')
        ->set('selectedShippingRateId', $rate->getKey())
        ->call('selectShippingMethod')
        ->assertSet('step', 'payment')
        ->set('discountCode', 'SAVE10')
        ->call('applyDiscount')
        ->assertHasNoErrors()
        ->set('paymentMethod', 'credit_card')
        ->call('selectPaymentMethod')
        ->assertSet('step', 'reserved');

    $checkout = Checkout::withoutGlobalScopes()->where('cart_id', $cart->getKey())->firstOrFail();
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($checkout->discount_code)->toBe('SAVE10')
        ->and($checkout->totals_json['discount'])->toBeGreaterThan(0)
        ->and($inventory->quantity_reserved)->toBe(2);
});
<?php

namespace App\Services;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Discount;
use App\Models\OrderLine;
use App\Models\ProductVariant;
use App\Models\Store;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class DiscountService
{
    public function validate(string $code, Store $store, Cart $cart): Discount
    {
        $normalizedCode = mb_strtolower(trim($code));

        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Code)
            ->whereRaw('lower(code) = ?', [$normalizedCode])
            ->first();

        if (! $discount instanceof Discount) {
            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
        }

        $this->validateDiscountForCart($discount, $cart);

        return $discount;
    }

    /**
     * @return Collection<int, Discount>
     */
    public function automaticForCart(Store $store, Cart $cart): Collection
    {
        return Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Automatic->value)
            ->where('status', DiscountStatus::Active->value)
            ->orderBy('id')
            ->get()
            ->filter(function (Discount $discount) use ($cart): bool {
                try {
                    $this->validateDiscountForCart($discount, $cart);

                    return true;
                } catch (InvalidDiscountException) {
                    return false;
                }
            })
            ->values();
    }

    private function validateDiscountForCart(Discount $discount, Cart $cart): void
    {
        if ($discount->status !== DiscountStatus::Active) {
            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
        }

        if ($discount->starts_at->isFuture()) {
            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
        }

        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
        }

        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
        }

        if ($this->onePerCustomer($discount) && $this->customerHasUsedDiscount($discount, $cart)) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.');
        }

        $lines = $this->cartLines($cart);
        $subtotal = $lines->sum('line_subtotal_amount');
        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));

        if ($minimum > 0 && $subtotal < $minimum) {
            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
        }

        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
        }
    }

    /**
     * @param  array<int, CartLine>  $lines
     */
    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
    {
        if ($discount->value_type === DiscountValueType::FreeShipping) {
            return new DiscountResult(0, [], true);
        }

        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
        $qualifyingSubtotal = $qualifyingLines->sum('line_total_amount');

        if ($qualifyingSubtotal <= 0) {
            return new DiscountResult(0, []);
        }

        $discountAmount = match ($discount->value_type) {
            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
            DiscountValueType::FreeShipping => 0,
        };

        $remaining = $discountAmount;
        $allocations = [];
        $lastIndex = $qualifyingLines->keys()->last();

        foreach ($qualifyingLines as $index => $line) {
            if ($index === $lastIndex) {
                $allocations[$line->getKey()] = $remaining;

                continue;
            }

            $allocation = (int) round($discountAmount * $line->line_total_amount / $qualifyingSubtotal);
            $allocations[$line->getKey()] = $allocation;
            $remaining -= $allocation;
        }

        return new DiscountResult($discountAmount, $allocations);
    }

    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
    {
        return DB::transaction(function () use ($cart, $discount): DiscountResult {
            $lines = $this->cartLines($cart);
            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());

            $lines->each(function (CartLine $line) use ($result): void {
                $discountAmount = $result->allocations[$line->getKey()] ?? 0;

                $line->forceFill([
                    'line_discount_amount' => $line->line_discount_amount + $discountAmount,
                    'line_total_amount' => max(0, $line->line_total_amount - $discountAmount),
                ])->save();
            });

            return $result;
        });
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  Collection<int, CartLine>  $lines
     * @return Collection<int, CartLine>
     */
    private function qualifyingLines(Discount $discount, Collection $lines): Collection
    {
        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();
        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();

        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
            return $lines;
        }

        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
            $variant = ProductVariant::withoutGlobalScopes()
                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
                ->find($line->variant_id);

            if (! $variant instanceof ProductVariant || $variant->product === null) {
                return false;
            }

            if ($productIds->contains((int) $variant->product_id)) {
                return true;
            }

            return $variant->product->collections
                ->pluck('id')
                ->map(fn (int $id): int => $id)
                ->intersect($collectionIds)
                ->isNotEmpty();
        });
    }

    private function onePerCustomer(Discount $discount): bool
    {
        return (bool) data_get($discount->rules_json, 'one_per_customer', false);
    }

    private function customerHasUsedDiscount(Discount $discount, Cart $cart): bool
    {
        if ($cart->customer_id === null) {
            return false;
        }

        $discountCode = $discount->code ? mb_strtolower($discount->code) : null;

        return OrderLine::query()
            ->whereHas('order', function ($query) use ($discount, $cart): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $discount->store_id)
                    ->where('customer_id', $cart->customer_id)
                    ->where('discount_amount', '>', 0);
            })
            ->get()
            ->contains(function (OrderLine $line) use ($discount, $discountCode): bool {
                return collect($line->discount_allocations_json ?? [])
                    ->contains(function (mixed $allocation) use ($discount, $discountCode): bool {
                        $allocationDiscountId = data_get($allocation, 'discount_id');

                        if ($allocationDiscountId !== null && (int) $allocationDiscountId === $discount->getKey()) {
                            return true;
                        }

                        if ($discountCode === null) {
                            return false;
                        }

                        return mb_strtolower((string) data_get($allocation, 'code', '')) === $discountCode;
                    });
            });
    }
}


cmd: sed -n '1,120p' app/Enums/DiscountStatus.php && sed -n '1,160p' app/Models/Discount.php


command: sed -n '1,120p' app/Enums/DiscountStatus.php && sed -n '1,160p' app/Models/Discount.php

DiscountStatus.php

type: read

Discount.php

cmd: sed -n '1,160p' app/Models/Discount.php

aggregated_output:
<?php

namespace App\Enums;

enum DiscountStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Expired = 'expired';
    case Disabled = 'disabled';
}
<?php

namespace App\Models;

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

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

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}

status: completed


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

namespace App\Enums;

enum DiscountStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Expired = 'expired';
    case Disabled = 'disabled';
}
<?php

namespace App\Models;

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

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

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

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

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}


message: Suite 8 depends on discount fixtures the current seeder only partially provides. I’m going to align the seed data with the spec while keeping the existing `SAVE10` and `5OFF` codes so the earlier feature tests remain valid.

phase: commentary


type: message

role: assistant

text: Suite 8 depends on discount fixtures the current seeder only partially provides. I’m going to align the seed data with the spec while keeping the existing `SAVE10` and `5OFF` codes so the earlier feature tests remain valid.


cmd: sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/laravel-best-practices/SKILL.md


command: sed -n '1,220p' /Users/fabianwesner/Herd/shop/.agents/skills/laravel-best-practices/SKILL.md

aggregated_output:
---
name: laravel-best-practices
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
license: MIT
metadata:
  author: laravel
---

# Laravel Best Practices

Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.

## Consistency First

Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.

Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.

## Quick Reference

### 1. Database Performance → `rules/db-performance.md`

- Eager load with `with()` to prevent N+1 queries
- Enable `Model::preventLazyLoading()` in development
- Select only needed columns, avoid `SELECT *`
- `chunk()` / `chunkById()` for large datasets
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
- `withCount()` instead of loading relations to count
- `cursor()` for memory-efficient read-only iteration
- Never query in Blade templates

### 2. Advanced Query Patterns → `rules/advanced-queries.md`

- `addSelect()` subqueries over eager-loading entire has-many for a single value
- Dynamic relationships via subquery FK + `belongsTo`
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
- `setRelation()` to prevent circular N+1 queries
- `whereIn` + `pluck()` over `whereHas` for better index usage
- Two simple queries can beat one complex query
- Compound indexes matching `orderBy` column order
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)

### 3. Security → `rules/security.md`

- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
- No raw SQL with user input — use Eloquent or query builder
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
- Validate MIME type, extension, and size for file uploads
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields

### 4. Caching → `rules/caching.md`

- `Cache::remember()` over manual get/put
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
- `Cache::memo()` to avoid redundant cache hits within a request
- Cache tags to invalidate related groups
- `Cache::add()` for atomic conditional writes
- `once()` to memoize per-request or per-object lifetime
- `Cache::lock()` / `lockForUpdate()` for race conditions
- Failover cache stores in production

### 5. Eloquent Patterns → `rules/eloquent.md`

- Correct relationship types with return type hints
- Local scopes for reusable query constraints
- Global scopes sparingly — document their existence
- Attribute casts in the `casts()` method
- Cast date columns, use Carbon instances in templates
- `whereBelongsTo($model)` for cleaner queries
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries

### 6. Validation & Forms → `rules/validation.md`

- Form Request classes, not inline validation
- Array notation `['required', 'email']` for new code; follow existing convention
- `$request->validated()` only — never `$request->all()`
- `Rule::when()` for conditional validation
- `after()` instead of `withValidator()`

### 7. Configuration → `rules/config.md`

- `env()` only inside config files
- `App::environment()` or `app()->isProduction()`
- Config, lang files, and constants over hardcoded text

### 8. Testing Patterns → `rules/testing.md`

- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
- `assertModelExists()` over raw `assertDatabaseHas()`
- Factory states and sequences over manual overrides
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
- `recycle()` to share relationship instances across factories

### 9. Queue & Job Patterns → `rules/queue-jobs.md`

- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios

### 10. Routing & Controllers → `rules/routing.md`

- Implicit route model binding
- Scoped bindings for nested resources
- `Route::resource()` or `apiResource()`
- Methods under 10 lines — extract to actions/services
- Type-hint Form Requests for auto-validation

### 11. HTTP Client → `rules/http-client.md`

- Explicit `timeout` and `connectTimeout` on every request
- `retry()` with exponential backoff for external APIs
- Check response status or use `throw()`
- `Http::pool()` for concurrent independent requests
- `Http::fake()` and `preventStrayRequests()` in tests

### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`

- Event discovery over manual registration; `event:cache` in production
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
- Queue notifications and mailables with `ShouldQueue`
- On-demand notifications for non-user recipients
- `HasLocalePreference` on notifiable models
- `assertQueued()` not `assertSent()` for queued mailables
- Markdown mailables for transactional emails

### 13. Error Handling → `rules/error-handling.md`

- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
- `ShouldntReport` for exceptions that should never log
- Throttle high-volume exceptions to protect log sinks
- `dontReportDuplicates()` for multi-catch scenarios
- Force JSON rendering for API routes
- Structured context via `context()` on exception classes

### 14. Task Scheduling → `rules/scheduling.md`

- `withoutOverlapping()` on variable-duration tasks
- `onOneServer()` on multi-server deployments
- `runInBackground()` for concurrent long tasks
- `environments()` to restrict to appropriate environments
- `takeUntilTimeout()` for time-bounded processing
- Schedule groups for shared configuration

### 15. Architecture → `rules/architecture.md`

- Single-purpose Action classes; dependency injection over `app()` helper
- Prefer official Laravel packages and follow conventions, don't override defaults
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution

### 16. Migrations → `rules/migrations.md`

- Generate migrations with `php artisan make:migration`
- `constrained()` for foreign keys
- Never modify migrations that have run in production
- Add indexes in the migration, not as an afterthought
- Mirror column defaults in model `$attributes`
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
- One concern per migration — never mix DDL and DML

### 17. Collections → `rules/collections.md`

- Higher-order messages for simple collection operations
- `cursor()` vs. `lazy()` — choose based on relationship needs
- `lazyById()` when updating records while iterating
- `toQuery()` for bulk operations on collections

### 18. Blade & Views → `rules/blade-views.md`

- `$attributes->merge()` in component templates
- Blade components over `@include`; `@pushOnce` for per-component scripts
- View Composers for shared view data
- `@aware` for deeply nested component props

### 19. Conventions & Style → `rules/style.md`

- Follow Laravel naming conventions for all entities
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
- No JS/CSS in Blade, no HTML in PHP classes
- Code should be readable; comments only for config files

## How to Apply

Always use a sub-agent to read rule files and explore this skill's content.

1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version


output:
Chunk ID: 510038
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2249
Output:
---
name: laravel-best-practices
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
license: MIT
metadata:
  author: laravel
---

# Laravel Best Practices

Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.

## Consistency First

Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.

Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.

## Quick Reference

### 1. Database Performance → `rules/db-performance.md`

- Eager load with `with()` to prevent N+1 queries
- Enable `Model::preventLazyLoading()` in development
- Select only needed columns, avoid `SELECT *`
- `chunk()` / `chunkById()` for large datasets
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
- `withCount()` instead of loading relations to count
- `cursor()` for memory-efficient read-only iteration
- Never query in Blade templates

### 2. Advanced Query Patterns → `rules/advanced-queries.md`

- `addSelect()` subqueries over eager-loading entire has-many for a single value
- Dynamic relationships via subquery FK + `belongsTo`
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
- `setRelation()` to prevent circular N+1 queries
- `whereIn` + `pluck()` over `whereHas` for better index usage
- Two simple queries can beat one complex query
- Compound indexes matching `orderBy` column order
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)

### 3. Security → `rules/security.md`

- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
- No raw SQL with user input — use Eloquent or query builder
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
- Validate MIME type, extension, and size for file uploads
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields

### 4. Caching → `rules/caching.md`

- `Cache::remember()` over manual get/put
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
- `Cache::memo()` to avoid redundant cache hits within a request
- Cache tags to invalidate related groups
- `Cache::add()` for atomic conditional writes
- `once()` to memoize per-request or per-object lifetime
- `Cache::lock()` / `lockForUpdate()` for race conditions
- Failover cache stores in production

### 5. Eloquent Patterns → `rules/eloquent.md`

- Correct relationship types with return type hints
- Local scopes for reusable query constraints
- Global scopes sparingly — document their existence
- Attribute casts in the `casts()` method
- Cast date columns, use Carbon instances in templates
- `whereBelongsTo($model)` for cleaner queries
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries

### 6. Validation & Forms → `rules/validation.md`

- Form Request classes, not inline validation
- Array notation `['required', 'email']` for new code; follow existing convention
- `$request->validated()` only — never `$request->all()`
- `Rule::when()` for conditional validation
- `after()` instead of `withValidator()`

### 7. Configuration → `rules/config.md`

- `env()` only inside config files
- `App::environment()` or `app()->isProduction()`
- Config, lang files, and constants over hardcoded text

### 8. Testing Patterns → `rules/testing.md`

- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
- `assertModelExists()` over raw `assertDatabaseHas()`
- Factory states and sequences over manual overrides
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
- `recycle()` to share relationship instances across factories

### 9. Queue & Job Patterns → `rules/queue-jobs.md`

- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios

### 10. Routing & Controllers → `rules/routing.md`

- Implicit route model binding
- Scoped bindings for nested resources
- `Route::resource()` or `apiResource()`
- Methods under 10 lines — extract to actions/services
- Type-hint Form Requests for auto-validation

### 11. HTTP Client → `rules/http-client.md`

- Explicit `timeout` and `connectTimeout` on every request
- `retry()` with exponential backoff for external APIs
- Check response status or use `throw()`
- `Http::pool()` for concurrent independent requests
- `Http::fake()` and `preventStrayRequests()` in tests

### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`

- Event discovery over manual registration; `event:cache` in production
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
- Queue notifications and mailables with `ShouldQueue`
- On-demand notifications for non-user recipients
- `HasLocalePreference` on notifiable models
- `assertQueued()` not `assertSent()` for queued mailables
- Markdown mailables for transactional emails

### 13. Error Handling → `rules/error-handling.md`

- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
- `ShouldntReport` for exceptions that should never log
- Throttle high-volume exceptions to protect log sinks
- `dontReportDuplicates()` for multi-catch scenarios
- Force JSON rendering for API routes
- Structured context via `context()` on exception classes

### 14. Task Scheduling → `rules/scheduling.md`

- `withoutOverlapping()` on variable-duration tasks
- `onOneServer()` on multi-server deployments
- `runInBackground()` for concurrent long tasks
- `environments()` to restrict to appropriate environments
- `takeUntilTimeout()` for time-bounded processing
- Schedule groups for shared configuration

### 15. Architecture → `rules/architecture.md`

- Single-purpose Action classes; dependency injection over `app()` helper
- Prefer official Laravel packages and follow conventions, don't override defaults
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution

### 16. Migrations → `rules/migrations.md`

- Generate migrations with `php artisan make:migration`
- `constrained()` for foreign keys
- Never modify migrations that have run in production
- Add indexes in the migration, not as an afterthought
- Mirror column defaults in model `$attributes`
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
- One concern per migration — never mix DDL and DML

### 17. Collections → `rules/collections.md`

- Higher-order messages for simple collection operations
- `cursor()` vs. `lazy()` — choose based on relationship needs
- `lazyById()` when updating records while iterating
- `toQuery()` for bulk operations on collections

### 18. Blade & Views → `rules/blade-views.md`

- `$attributes->merge()` in component templates
- Blade components over `@include`; `@pushOnce` for per-component scripts
- View Composers for shared view data
- `@aware` for deeply nested component props

### 19. Conventions & Style → `rules/style.md`

- Follow Laravel naming conventions for all entities
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
- No JS/CSS in Blade, no HTML in PHP classes
- Code should be readable; comments only for config files

## How to Apply

Always use a sub-agent to read rule files and explore this skill's content.

1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version


cmd: sed -n '1,220p' database/factories/DiscountFactory.php && sed -n '1435,1455p' specs/07-SEEDERS-AND-TEST-DATA.md


command: sed -n '1,220p' database/factories/DiscountFactory.php && sed -n '1435,1455p' specs/07-SEEDERS-AND-TEST-DATA.md

DiscountFactory.php

aggregated_output:
<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}
### 3.11 DiscountSeeder

**Acme Fashion - 5 discount codes:**

| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |
|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|
| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |
| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |
| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |
| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |
| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |

**Behavioral expectations:**
- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).
- FLAT5: Applies flat 5.00 EUR discount. Never used.
- FREESHIP: Removes shipping cost. Used once.
- EXPIRED20: Expired - checkout must reject this code with "discount expired" error.
- MAXED: Usage limit of 5 reached - checkout must reject this code with "discount limit reached" error.

---


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

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}
### 3.11 DiscountSeeder

**Acme Fashion - 5 discount codes:**

| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |
|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|
| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |
| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |
| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |
| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |
| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |

**Behavioral expectations:**
- WELCOME10: Applies 10% off to orders with subtotal >= 20.00 EUR. Already used 3 times (has capacity remaining since no usage_limit).
- FLAT5: Applies flat 5.00 EUR discount. Never used.
- FREESHIP: Removes shipping cost. Used once.
- EXPIRED20: Expired - checkout must reject this code with "discount expired" error.
- MAXED: Usage limit of 5 reached - checkout must reject this code with "discount limit reached" error.

---


cmd: rg "DiscountStatus|value_type|usage_limit" database/seeders app -n


command: rg "DiscountStatus|value_type|usage_limit" database/seeders app -n

type: search

cmd: rg 'DiscountStatus|value_type|usage_limit' database/seeders app -n

path: seeders

aggregated_output:
database/seeders/DiscountSeeder.php:25:                        'value_type' => $discount['value_type'],
database/seeders/DiscountSeeder.php:29:                        'usage_limit' => null,
database/seeders/DiscountSeeder.php:40:     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>}>
database/seeders/DiscountSeeder.php:45:            ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']],
database/seeders/DiscountSeeder.php:46:            ['code' => '5OFF', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => ['min_purchase_amount' => 2500, 'customer_eligibility' => 'all']],
database/seeders/DiscountSeeder.php:47:            ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
app/Services/DiscountService.php:5:use App\Enums\DiscountStatus;
app/Services/DiscountService.php:48:            ->where('status', DiscountStatus::Active->value)
app/Services/DiscountService.php:65:        if ($discount->status !== DiscountStatus::Active) {
app/Services/DiscountService.php:77:        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
app/Services/DiscountService.php:78:            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
app/Services/DiscountService.php:82:            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.');
app/Services/DiscountService.php:103:        if ($discount->value_type === DiscountValueType::FreeShipping) {
app/Services/DiscountService.php:114:        $discountAmount = match ($discount->value_type) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:108:        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');
app/Http/Controllers/Api/Admin/V1/DiscountController.php:141:            'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])],
app/Http/Controllers/Api/Admin/V1/DiscountController.php:160:            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
app/Http/Controllers/Api/Admin/V1/DiscountController.php:198:        $valueType = (string) $validated['value_type'];
app/Http/Controllers/Api/Admin/V1/DiscountController.php:204:            'value_type' => $valueType,
app/Http/Controllers/Api/Admin/V1/DiscountController.php:208:            'usage_limit' => $validated['usage_limit'] ?? null,
app/Http/Controllers/Api/Admin/V1/DiscountController.php:222:        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);
app/Http/Controllers/Api/Admin/V1/DiscountController.php:224:        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:232:        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
app/Models/Discount.php:5:use App\Enums\DiscountStatus;
app/Models/Discount.php:25:        'value_type',
app/Models/Discount.php:29:        'usage_limit',
app/Models/Discount.php:61:            'value_type' => DiscountValueType::class,
app/Models/Discount.php:65:            'usage_limit' => 'integer',
app/Models/Discount.php:68:            'status' => DiscountStatus::class,
app/Enums/DiscountStatus.php:5:enum DiscountStatus: string
app/Http/Resources/Admin/V1/DiscountResource.php:22:            'value_type' => $this->value_type?->value,
app/Http/Resources/Admin/V1/DiscountResource.php:26:            'usage_limit' => $this->usage_limit,
app/Livewire/Admin/Discounts/Form.php:5:use App\Enums\DiscountStatus;
app/Livewire/Admin/Discounts/Form.php:255:        $this->valueType = $discount->value_type->value;
app/Livewire/Admin/Discounts/Form.php:256:        $this->valueAmount = $discount->value_type === DiscountValueType::Fixed
app/Livewire/Admin/Discounts/Form.php:268:        $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : '';
app/Livewire/Admin/Discounts/Form.php:272:        $this->isActive = $discount->status === DiscountStatus::Active;
app/Livewire/Admin/Discounts/Form.php:284:            'value_type' => DiscountValueType::from($this->valueType),
app/Livewire/Admin/Discounts/Form.php:288:            'usage_limit' => $this->usageLimit !== '' ? (int) $this->usageLimit : null,
app/Livewire/Admin/Discounts/Form.php:402:    private function statusForPayload(): DiscountStatus
app/Livewire/Admin/Discounts/Form.php:404:        if ($this->discount?->status === DiscountStatus::Expired) {
app/Livewire/Admin/Discounts/Form.php:405:            return DiscountStatus::Expired;
app/Livewire/Admin/Discounts/Form.php:409:            return DiscountStatus::Active;
app/Livewire/Admin/Discounts/Form.php:412:        if ($this->discount?->status === DiscountStatus::Draft || ! $this->discount instanceof Discount) {
app/Livewire/Admin/Discounts/Form.php:413:            return DiscountStatus::Draft;
app/Livewire/Admin/Discounts/Form.php:416:        return DiscountStatus::Disabled;
app/Livewire/Admin/Discounts/Index.php:5:use App\Enums\DiscountStatus;
app/Livewire/Admin/Discounts/Index.php:82:        return match ($discount->value_type) {
app/Livewire/Admin/Discounts/Index.php:91:        if ($discount->status === DiscountStatus::Disabled) {
app/Livewire/Admin/Discounts/Index.php:99:        if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
app/Livewire/Admin/Discounts/Index.php:129:                ->where('status', DiscountStatus::Active->value)
app/Livewire/Admin/Discounts/Index.php:136:                    ->where('status', DiscountStatus::Expired->value)
app/Livewire/Admin/Discounts/Index.php:139:                            ->where('status', DiscountStatus::Active->value)
app/Livewire/Admin/Discounts/Index.php:144:                ->where('status', DiscountStatus::Active->value)


output:
Chunk ID: 5719d8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1538
Output:
database/seeders/DiscountSeeder.php:25:                        'value_type' => $discount['value_type'],
database/seeders/DiscountSeeder.php:29:                        'usage_limit' => null,
database/seeders/DiscountSeeder.php:40:     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>}>
database/seeders/DiscountSeeder.php:45:            ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']],
database/seeders/DiscountSeeder.php:46:            ['code' => '5OFF', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => ['min_purchase_amount' => 2500, 'customer_eligibility' => 'all']],
database/seeders/DiscountSeeder.php:47:            ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
app/Services/DiscountService.php:5:use App\Enums\DiscountStatus;
app/Services/DiscountService.php:48:            ->where('status', DiscountStatus::Active->value)
app/Services/DiscountService.php:65:        if ($discount->status !== DiscountStatus::Active) {
app/Services/DiscountService.php:77:        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
app/Services/DiscountService.php:78:            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
app/Services/DiscountService.php:82:            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount has already been used by this customer.');
app/Services/DiscountService.php:103:        if ($discount->value_type === DiscountValueType::FreeShipping) {
app/Services/DiscountService.php:114:        $discountAmount = match ($discount->value_type) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:108:        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');
app/Http/Controllers/Api/Admin/V1/DiscountController.php:141:            'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])],
app/Http/Controllers/Api/Admin/V1/DiscountController.php:160:            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
app/Http/Controllers/Api/Admin/V1/DiscountController.php:198:        $valueType = (string) $validated['value_type'];
app/Http/Controllers/Api/Admin/V1/DiscountController.php:204:            'value_type' => $valueType,
app/Http/Controllers/Api/Admin/V1/DiscountController.php:208:            'usage_limit' => $validated['usage_limit'] ?? null,
app/Http/Controllers/Api/Admin/V1/DiscountController.php:222:        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);
app/Http/Controllers/Api/Admin/V1/DiscountController.php:224:        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:232:        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
app/Models/Discount.php:5:use App\Enums\DiscountStatus;
app/Models/Discount.php:25:        'value_type',
app/Models/Discount.php:29:        'usage_limit',
app/Models/Discount.php:61:            'value_type' => DiscountValueType::class,
app/Models/Discount.php:65:            'usage_limit' => 'integer',
app/Models/Discount.php:68:            'status' => DiscountStatus::class,
app/Enums/DiscountStatus.php:5:enum DiscountStatus: string
app/Http/Resources/Admin/V1/DiscountResource.php:22:            'value_type' => $this->value_type?->value,
app/Http/Resources/Admin/V1/DiscountResource.php:26:            'usage_limit' => $this->usage_limit,
app/Livewire/Admin/Discounts/Form.php:5:use App\Enums\DiscountStatus;
app/Livewire/Admin/Discounts/Form.php:255:        $this->valueType = $discount->value_type->value;
app/Livewire/Admin/Discounts/Form.php:256:        $this->valueAmount = $discount->value_type === DiscountValueType::Fixed
app/Livewire/Admin/Discounts/Form.php:268:        $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : '';
app/Livewire/Admin/Discounts/Form.php:272:        $this->isActive = $discount->status === DiscountStatus::Active;
app/Livewire/Admin/Discounts/Form.php:284:            'value_type' => DiscountValueType::from($this->valueType),
app/Livewire/Admin/Discounts/Form.php:288:            'usage_limit' => $this->usageLimit !== '' ? (int) $this->usageLimit : null,
app/Livewire/Admin/Discounts/Form.php:402:    private function statusForPayload(): DiscountStatus
app/Livewire/Admin/Discounts/Form.php:404:        if ($this->discount?->status === DiscountStatus::Expired) {
app/Livewire/Admin/Discounts/Form.php:405:            return DiscountStatus::Expired;
app/Livewire/Admin/Discounts/Form.php:409:            return DiscountStatus::Active;
app/Livewire/Admin/Discounts/Form.php:412:        if ($this->discount?->status === DiscountStatus::Draft || ! $this->discount instanceof Discount) {
app/Livewire/Admin/Discounts/Form.php:413:            return DiscountStatus::Draft;
app/Livewire/Admin/Discounts/Form.php:416:        return DiscountStatus::Disabled;
app/Livewire/Admin/Discounts/Index.php:5:use App\Enums\DiscountStatus;
app/Livewire/Admin/Discounts/Index.php:82:        return match ($discount->value_type) {
app/Livewire/Admin/Discounts/Index.php:91:        if ($discount->status === DiscountStatus::Disabled) {
app/Livewire/Admin/Discounts/Index.php:99:        if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
app/Livewire/Admin/Discounts/Index.php:129:                ->where('status', DiscountStatus::Active->value)
app/Livewire/Admin/Discounts/Index.php:136:                    ->where('status', DiscountStatus::Expired->value)
app/Livewire/Admin/Discounts/Index.php:139:                            ->where('status', DiscountStatus::Active->value)
app/Livewire/Admin/Discounts/Index.php:144:                ->where('status', DiscountStatus::Active->value)


cmd: rg "Discount is not active|Discount has expired|expired|usage limit" tests -n


command: rg "Discount is not active|Discount has expired|expired|usage limit" tests -n

type: search

cmd: rg 'Discount is not active|Discount has expired|expired|usage limit' tests -n

query: Discount is not active|Discount has expired|expired|usage limit

path: tests

aggregated_output:
tests/Feature/Api/AdminOrderApiTest.php:212:test('admin order api rejects expired bearer tokens', function (): void {
tests/Feature/Checkout/CheckoutServiceTest.php:111:    $expired = app(CheckoutService::class)->expireCheckout($checkout);
tests/Feature/Checkout/CheckoutServiceTest.php:112:    expect($expired->status)->toBe(CheckoutStatus::Expired)
tests/Feature/Admin/DiscountManagementTest.php:142:        ->set('statusFilter', 'expired')
tests/Feature/Admin/DiscountManagementTest.php:239:test('admin discount form creates drafts by default and preserves expired discounts', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:258:    $expired = Discount::factory()->create([
tests/Feature/Admin/DiscountManagementTest.php:267:        ->test(AdminDiscountForm::class, ['discount' => $expired])
tests/Feature/Admin/DiscountManagementTest.php:275:        ->and($expired->refresh()->status)->toBe(DiscountStatus::Expired);


output:
Chunk ID: 841f0b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 235
Output:
tests/Feature/Api/AdminOrderApiTest.php:212:test('admin order api rejects expired bearer tokens', function (): void {
tests/Feature/Checkout/CheckoutServiceTest.php:111:    $expired = app(CheckoutService::class)->expireCheckout($checkout);
tests/Feature/Checkout/CheckoutServiceTest.php:112:    expect($expired->status)->toBe(CheckoutStatus::Expired)
tests/Feature/Admin/DiscountManagementTest.php:142:        ->set('statusFilter', 'expired')
tests/Feature/Admin/DiscountManagementTest.php:239:test('admin discount form creates drafts by default and preserves expired discounts', function (): void {
tests/Feature/Admin/DiscountManagementTest.php:258:    $expired = Discount::factory()->create([
tests/Feature/Admin/DiscountManagementTest.php:267:        ->test(AdminDiscountForm::class, ['discount' => $expired])
tests/Feature/Admin/DiscountManagementTest.php:275:        ->and($expired->refresh()->status)->toBe(DiscountStatus::Expired);


status: completed

input:
*** Begin Patch
*** Update File: database/seeders/DiscountSeeder.php
@@
                     [
                         'type' => 'code',
                         'value_type' => $discount['value_type'],
                         'value_amount' => $discount['value_amount'],
-                        'starts_at' => now()->subDay(),
-                        'ends_at' => now()->addMonth(),
-                        'usage_limit' => null,
-                        'usage_count' => 0,
+                        'starts_at' => $discount['starts_at'] ?? now()->subDay(),
+                        'ends_at' => $discount['ends_at'] ?? now()->addMonth(),
+                        'usage_limit' => $discount['usage_limit'] ?? null,
+                        'usage_count' => $discount['usage_count'] ?? 0,
                         'rules_json' => $discount['rules_json'],
-                        'status' => 'active',
+                        'status' => $discount['status'] ?? 'active',
                     ],
                 );
             }
         });
@@
-     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>}>
+     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>, starts_at?: \Illuminate\Support\Carbon, ends_at?: \Illuminate\Support\Carbon, usage_limit?: int|null, usage_count?: int, status?: string}>
      */
     private function discounts(): array
     {
         return [
+            ['code' => 'WELCOME10', 'value_type' => 'percent', 'value_amount' => 10, 'usage_count' => 3, 'rules_json' => ['min_purchase_amount' => 2000]],
+            ['code' => 'FLAT5', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => []],
             ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']],
             ['code' => '5OFF', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => ['min_purchase_amount' => 2500, 'customer_eligibility' => 'all']],
             ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
+            ['code' => 'EXPIRED20', 'value_type' => 'percent', 'value_amount' => 20, 'ends_at' => now()->subDay(), 'rules_json' => [], 'status' => 'expired'],
+            ['code' => 'MAXED', 'value_type' => 'percent', 'value_amount' => 10, 'usage_limit' => 5, 'usage_count' => 5, 'rules_json' => []],
         ];
     }
 }
*** End Patch


stdout:
Success. Updated the following files:
M database/seeders/DiscountSeeder.php

type: update

unified_diff:
@@ -26,8 +26,8 @@
                         'value_amount' => $discount['value_amount'],
-                        'starts_at' => now()->subDay(),
-                        'ends_at' => now()->addMonth(),
-                        'usage_limit' => null,
-                        'usage_count' => 0,
+                        'starts_at' => $discount['starts_at'] ?? now()->subDay(),
+                        'ends_at' => $discount['ends_at'] ?? now()->addMonth(),
+                        'usage_limit' => $discount['usage_limit'] ?? null,
+                        'usage_count' => $discount['usage_count'] ?? 0,
                         'rules_json' => $discount['rules_json'],
-                        'status' => 'active',
+                        'status' => $discount['status'] ?? 'active',
                     ],
@@ -39,3 +39,3 @@
     /**
-     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>}>
+     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>, starts_at?: \Illuminate\Support\Carbon, ends_at?: \Illuminate\Support\Carbon, usage_limit?: int|null, usage_count?: int, status?: string}>
      */
@@ -44,2 +44,4 @@
         return [
+            ['code' => 'WELCOME10', 'value_type' => 'percent', 'value_amount' => 10, 'usage_count' => 3, 'rules_json' => ['min_purchase_amount' => 2000]],
+            ['code' => 'FLAT5', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => []],
             ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']],
@@ -47,2 +49,4 @@
             ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
+            ['code' => 'EXPIRED20', 'value_type' => 'percent', 'value_amount' => 20, 'ends_at' => now()->subDay(), 'rules_json' => [], 'status' => 'expired'],
+            ['code' => 'MAXED', 'value_type' => 'percent', 'value_amount' => 10, 'usage_limit' => 5, 'usage_count' => 5, 'rules_json' => []],
         ];


output: {"output":"Success. Updated the following files:\nM database/seeders/DiscountSeeder.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Services/DiscountService.php
@@
     private function validateDiscountForCart(Discount $discount, Cart $cart): void
     {
-        if ($discount->status !== DiscountStatus::Active) {
-            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
-        }
-
-        if ($discount->starts_at->isFuture()) {
-            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
-        }
-
-        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
+        if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
             throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
         }

+        if ($discount->status !== DiscountStatus::Active) {
+            throw InvalidDiscountException::because('discount_not_active', 'Discount is not active.');
+        }
+
+        if ($discount->starts_at->isFuture()) {
+            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
+        }
+
         if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
             throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
         }
*** End Patch


stdout:
Success. Updated the following files:
M app/Services/DiscountService.php

unified_diff:
@@ -64,4 +64,8 @@
     {
+        if ($discount->status === DiscountStatus::Expired || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
+            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
+        }
+
         if ($discount->status !== DiscountStatus::Active) {
-            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
+            throw InvalidDiscountException::because('discount_not_active', 'Discount is not active.');
         }
@@ -70,6 +74,2 @@
             throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
-        }
-
-        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
-            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
         }


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


message: The seed/service changes are small: the spec discount codes now exist, while an expired status produces the expected expired message. I’m creating the Suite 8 browser test file next and will use it to validate the whole cart flow against those fixtures.


text: The seed/service changes are small: the spec discount codes now exist, while an expired status produces the expected expired message. I’m creating the Suite 8 browser test file next and will use it to validate the whole cart flow against those fixtures.


cmd: php artisan make:test Browser/Storefront/CartTest --pest --no-interaction


output:
Chunk ID: 201ec0
Wall time: 0.1866 seconds
Process exited with code 0
Original token count: 22
Output:

   INFO  Test [tests/Feature/Browser/Storefront/CartTest.php] created successfully.


command: php artisan make:test Browser/Storefront/CartTest --pest --no-interaction

type: unknown

aggregated_output: INFO  Test [tests/Feature/Browser/Storefront/CartTest.php] created successfully.


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


command:
/bin/zsh
-lc
rmdir tests/Feature/Browser/Storefront

cmd: rmdir tests/Feature/Browser/Storefront


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


command:
/bin/zsh
-lc
rmdir tests/Feature/Browser

cmd: rmdir tests/Feature/Browser


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


cmd: rg "toast|flux:toast|Added to cart|data-test" resources app tests -n


command: rg "toast|flux:toast|Added to cart|data-test" resources app tests -n

type: search

cmd: rg 'toast|flux:toast|Added to cart|data-test' resources app tests -n

query: toast|flux:toast|Added to cart|data-test

path: resources

aggregated_output:
tests/Browser/Admin/AuthenticationTest.php:94:        ->click('button[data-test="logout-button"]:visible')
app/Livewire/Admin/Developers/Index.php:70:        $this->dispatch('toast', type: 'success', message: __('API token generated'));
app/Livewire/Admin/Developers/Index.php:80:        $this->dispatch('toast', type: 'success', message: __('API token revoked'));
app/Livewire/Admin/Developers/Index.php:127:        $this->dispatch('toast', type: 'success', message: __('Webhook saved'));
app/Livewire/Admin/Developers/Index.php:135:        $this->dispatch('toast', type: 'success', message: __('Webhook deleted'));
resources/views/layouts/app/sidebar.blade.php:148:                            data-test="logout-button"
app/Livewire/Admin/Themes/Editor.php:89:        $this->dispatch('toast', type: 'success', message: __('Theme file saved'));
app/Livewire/Admin/Themes/Editor.php:107:        $this->dispatch('toast', type: 'success', message: __('Theme saved'));
app/Livewire/Admin/Themes/Index.php:59:        $this->dispatch('toast', type: 'success', message: __('Theme published'));
app/Livewire/Admin/Apps/Show.php:48:        $this->dispatch('toast', type: 'success', message: __('App uninstalled'));
app/Livewire/Admin/Apps/Index.php:38:        $this->dispatch('toast', type: 'success', message: __('App uninstalled'));
app/Livewire/Admin/Pages/Form.php:104:        $this->dispatch('toast', type: 'success', message: __('Page saved'));
app/Livewire/Admin/Collections/Form.php:110:        $this->dispatch('toast', type: 'success', message: __('Collection saved'));
app/Livewire/Admin/Collections/Index.php:33:        $this->dispatch('toast', type: 'success', message: __('Collection deleted'));
app/Livewire/Admin/Products/Form.php:236:        $this->dispatch('toast', type: 'success', message: __('Product saved'));
app/Livewire/Admin/Products/Form.php:254:        $this->dispatch('toast', type: 'success', message: __('Media uploaded'));
app/Livewire/Admin/Products/Index.php:143:        $this->dispatch('toast', type: 'success', message: __('Products updated'));
resources/views/livewire/admin/products/form.blade.php:48:                            data-test="product-media-input"
resources/views/livewire/admin/products/form.blade.php:52:                            <flux:button type="button" wire:click="uploadMedia" variant="primary" icon="cloud-arrow-up" wire:loading.attr="disabled" wire:target="newMedia,uploadMedia" data-test="product-media-upload-button">
app/Livewire/Admin/Orders/Show.php:68:            $this->dispatch('toast', type: 'success', message: __('Payment confirmed'));
app/Livewire/Admin/Orders/Show.php:96:            $this->dispatch('toast', type: 'success', message: __('Refund processed'));
app/Livewire/Admin/Orders/Show.php:136:            $this->dispatch('toast', type: 'success', message: __('Fulfillment created'));
app/Livewire/Admin/Orders/Show.php:148:            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as shipped'));
app/Livewire/Admin/Orders/Show.php:160:            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as delivered'));
app/Livewire/Admin/Discounts/Form.php:137:        $this->dispatch('toast', type: 'success', message: __('Discount saved'));
app/Livewire/Admin/Settings/Notifications.php:83:        $this->dispatch('toast', type: 'success', message: __('Notification settings saved'));
app/Livewire/Admin/Settings/Checkout.php:87:        $this->dispatch('toast', type: 'success', message: __('Checkout settings saved'));
app/Livewire/Admin/Settings/Shipping.php:115:        $this->dispatch('toast', type: 'success', message: __('Shipping zone saved'));
app/Livewire/Admin/Settings/Shipping.php:192:        $this->dispatch('toast', type: 'success', message: __('Shipping rate saved'));
app/Livewire/Admin/Navigation/Index.php:232:        $this->dispatch('toast', type: 'success', message: __('Navigation saved'));
app/Livewire/Storefront/Products/Show.php:85:        $this->dispatch('toast', type: 'success', message: __('Added to cart'));
app/Livewire/Admin/Settings/Index.php:99:        $this->dispatch('toast', type: 'success', message: __('Settings saved'));
app/Livewire/Admin/Settings/Index.php:133:        $this->dispatch('toast', type: 'success', message: __('Domain added'));
app/Livewire/Admin/Settings/Taxes.php:100:        $this->dispatch('toast', type: 'success', message: __('Tax settings saved'));
app/Livewire/Admin/Search/Settings.php:82:        $this->dispatch('toast', type: 'success', message: __('Search settings saved'));
app/Livewire/Admin/Search/Settings.php:104:        $this->dispatch('toast', type: 'success', message: __('Search index rebuilt'));
app/Livewire/Admin/Customers/Show.php:102:        $this->dispatch('toast', type: 'success', message: __('Address saved'));
app/Livewire/Admin/Customers/Show.php:115:        $this->dispatch('toast', type: 'success', message: __('Address deleted'));
app/Livewire/Admin/Customers/Show.php:130:        $this->dispatch('toast', type: 'success', message: __('Default address updated'));
resources/views/livewire/auth/login.blade.php:46:                <flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
resources/views/livewire/auth/verify-email.blade.php:23:                <flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
resources/views/livewire/auth/forgot-password.blade.php:21:            <flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
resources/views/components/desktop-user-menu.blade.php:8:        data-test="sidebar-menu-button"
resources/views/components/desktop-user-menu.blade.php:34:                    data-test="logout-button"
resources/views/livewire/admin/auth/login.blade.php:27:        <flux:button variant="primary" type="submit" class="w-full" data-test="admin-login-button">
resources/views/livewire/auth/confirm-password.blade.php:23:            <flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
resources/views/livewire/auth/register.blade.php:56:                <flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
resources/views/livewire/auth/reset-password.blade.php:46:                <flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
resources/views/livewire/storefront/account/auth/register.blade.php:43:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-register-button">
resources/views/livewire/storefront/account/auth/login.blade.php:31:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-login-button">
resources/views/livewire/storefront/account/auth/forgot-password.blade.php:21:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-forgot-password-button">
resources/views/livewire/storefront/account/auth/reset-password.blade.php:33:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-reset-password-button">


output:
Chunk ID: ed3057
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1790
Output:
tests/Browser/Admin/AuthenticationTest.php:94:        ->click('button[data-test="logout-button"]:visible')
app/Livewire/Admin/Developers/Index.php:70:        $this->dispatch('toast', type: 'success', message: __('API token generated'));
app/Livewire/Admin/Developers/Index.php:80:        $this->dispatch('toast', type: 'success', message: __('API token revoked'));
app/Livewire/Admin/Developers/Index.php:127:        $this->dispatch('toast', type: 'success', message: __('Webhook saved'));
app/Livewire/Admin/Developers/Index.php:135:        $this->dispatch('toast', type: 'success', message: __('Webhook deleted'));
resources/views/layouts/app/sidebar.blade.php:148:                            data-test="logout-button"
app/Livewire/Admin/Themes/Editor.php:89:        $this->dispatch('toast', type: 'success', message: __('Theme file saved'));
app/Livewire/Admin/Themes/Editor.php:107:        $this->dispatch('toast', type: 'success', message: __('Theme saved'));
app/Livewire/Admin/Themes/Index.php:59:        $this->dispatch('toast', type: 'success', message: __('Theme published'));
app/Livewire/Admin/Apps/Show.php:48:        $this->dispatch('toast', type: 'success', message: __('App uninstalled'));
app/Livewire/Admin/Apps/Index.php:38:        $this->dispatch('toast', type: 'success', message: __('App uninstalled'));
app/Livewire/Admin/Pages/Form.php:104:        $this->dispatch('toast', type: 'success', message: __('Page saved'));
app/Livewire/Admin/Collections/Form.php:110:        $this->dispatch('toast', type: 'success', message: __('Collection saved'));
app/Livewire/Admin/Collections/Index.php:33:        $this->dispatch('toast', type: 'success', message: __('Collection deleted'));
app/Livewire/Admin/Products/Form.php:236:        $this->dispatch('toast', type: 'success', message: __('Product saved'));
app/Livewire/Admin/Products/Form.php:254:        $this->dispatch('toast', type: 'success', message: __('Media uploaded'));
app/Livewire/Admin/Products/Index.php:143:        $this->dispatch('toast', type: 'success', message: __('Products updated'));
resources/views/livewire/admin/products/form.blade.php:48:                            data-test="product-media-input"
resources/views/livewire/admin/products/form.blade.php:52:                            <flux:button type="button" wire:click="uploadMedia" variant="primary" icon="cloud-arrow-up" wire:loading.attr="disabled" wire:target="newMedia,uploadMedia" data-test="product-media-upload-button">
app/Livewire/Admin/Orders/Show.php:68:            $this->dispatch('toast', type: 'success', message: __('Payment confirmed'));
app/Livewire/Admin/Orders/Show.php:96:            $this->dispatch('toast', type: 'success', message: __('Refund processed'));
app/Livewire/Admin/Orders/Show.php:136:            $this->dispatch('toast', type: 'success', message: __('Fulfillment created'));
app/Livewire/Admin/Orders/Show.php:148:            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as shipped'));
app/Livewire/Admin/Orders/Show.php:160:            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as delivered'));
app/Livewire/Admin/Discounts/Form.php:137:        $this->dispatch('toast', type: 'success', message: __('Discount saved'));
app/Livewire/Admin/Settings/Notifications.php:83:        $this->dispatch('toast', type: 'success', message: __('Notification settings saved'));
app/Livewire/Admin/Settings/Checkout.php:87:        $this->dispatch('toast', type: 'success', message: __('Checkout settings saved'));
app/Livewire/Admin/Settings/Shipping.php:115:        $this->dispatch('toast', type: 'success', message: __('Shipping zone saved'));
app/Livewire/Admin/Settings/Shipping.php:192:        $this->dispatch('toast', type: 'success', message: __('Shipping rate saved'));
app/Livewire/Admin/Navigation/Index.php:232:        $this->dispatch('toast', type: 'success', message: __('Navigation saved'));
app/Livewire/Storefront/Products/Show.php:85:        $this->dispatch('toast', type: 'success', message: __('Added to cart'));
app/Livewire/Admin/Settings/Index.php:99:        $this->dispatch('toast', type: 'success', message: __('Settings saved'));
app/Livewire/Admin/Settings/Index.php:133:        $this->dispatch('toast', type: 'success', message: __('Domain added'));
app/Livewire/Admin/Settings/Taxes.php:100:        $this->dispatch('toast', type: 'success', message: __('Tax settings saved'));
app/Livewire/Admin/Search/Settings.php:82:        $this->dispatch('toast', type: 'success', message: __('Search settings saved'));
app/Livewire/Admin/Search/Settings.php:104:        $this->dispatch('toast', type: 'success', message: __('Search index rebuilt'));
app/Livewire/Admin/Customers/Show.php:102:        $this->dispatch('toast', type: 'success', message: __('Address saved'));
app/Livewire/Admin/Customers/Show.php:115:        $this->dispatch('toast', type: 'success', message: __('Address deleted'));
app/Livewire/Admin/Customers/Show.php:130:        $this->dispatch('toast', type: 'success', message: __('Default address updated'));
resources/views/livewire/auth/login.blade.php:46:                <flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
resources/views/livewire/auth/verify-email.blade.php:23:                <flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
resources/views/livewire/auth/forgot-password.blade.php:21:            <flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
resources/views/components/desktop-user-menu.blade.php:8:        data-test="sidebar-menu-button"
resources/views/components/desktop-user-menu.blade.php:34:                    data-test="logout-button"
resources/views/livewire/admin/auth/login.blade.php:27:        <flux:button variant="primary" type="submit" class="w-full" data-test="admin-login-button">
resources/views/livewire/auth/confirm-password.blade.php:23:            <flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
resources/views/livewire/auth/register.blade.php:56:                <flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
resources/views/livewire/auth/reset-password.blade.php:46:                <flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
resources/views/livewire/storefront/account/auth/register.blade.php:43:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-register-button">
resources/views/livewire/storefront/account/auth/login.blade.php:31:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-login-button">
resources/views/livewire/storefront/account/auth/forgot-password.blade.php:21:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-forgot-password-button">
resources/views/livewire/storefront/account/auth/reset-password.blade.php:33:        <flux:button variant="primary" type="submit" class="w-full" data-test="customer-reset-password-button">


cmd: rg "Discount code was not found|discount_not_found|Invalid discount code" -n app tests resources specs


command: rg "Discount code was not found|discount_not_found|Invalid discount code" -n app tests resources specs

type: search

cmd: rg 'Discount code was not found|discount_not_found|Invalid discount code' -n app tests resources specs

query: Discount code was not found|discount_not_found|Invalid discount code

path: app

aggregated_output:
specs/05-BUSINESS-LOGIC.md:663:    B -->|No| C["Error: discount_not_found"]
specs/05-BUSINESS-LOGIC.md:683:| `discount_not_found` | Code does not exist in this store |
specs/04-STOREFRONT-UI.md:826:| Error state | Small red text below, e.g., "Invalid discount code" or "This code has expired" |
specs/04-STOREFRONT-UI.md:1916:| Invalid discount code | "Invalid discount code" | error |
specs/08-PLAYWRIGHT-E2E-PLAN.md:1437:    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]
specs/08-PLAYWRIGHT-E2E-PLAN.md:1452:| INVALID | n/a | n/a | n/a | does not exist | Rejected with "Invalid discount code" error |
specs/08-PLAYWRIGHT-E2E-PLAN.md:1588:- Page displays "Invalid discount code"
tests/Feature/Api/StorefrontCheckoutApiTest.php:193:        ->assertJsonPath('reason', 'discount_not_found');
app/Services/DiscountService.php:32:            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');


output:
Chunk ID: fd2e57
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 241
Output:
specs/05-BUSINESS-LOGIC.md:663:    B -->|No| C["Error: discount_not_found"]
specs/05-BUSINESS-LOGIC.md:683:| `discount_not_found` | Code does not exist in this store |
specs/04-STOREFRONT-UI.md:826:| Error state | Small red text below, e.g., "Invalid discount code" or "This code has expired" |
specs/04-STOREFRONT-UI.md:1916:| Invalid discount code | "Invalid discount code" | error |
specs/08-PLAYWRIGHT-E2E-PLAN.md:1437:    DiscountCheck -->|no: invalid| ErrorInvalid[Error: Invalid discount code]
specs/08-PLAYWRIGHT-E2E-PLAN.md:1452:| INVALID | n/a | n/a | n/a | does not exist | Rejected with "Invalid discount code" error |
specs/08-PLAYWRIGHT-E2E-PLAN.md:1588:- Page displays "Invalid discount code"
tests/Feature/Api/StorefrontCheckoutApiTest.php:193:        ->assertJsonPath('reason', 'discount_not_found');
app/Services/DiscountService.php:32:            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');


message: A few small implementation gaps are worth fixing before the browser tests: the cart heading should match the spec, the invalid-code copy should be customer-facing, and discount placeholders should point at the seeded spec code. I’m applying those, then I’ll write the Suite 8 tests.


text: A few small implementation gaps are worth fixing before the browser tests: the cart heading should match the spec, the invalid-code copy should be customer-facing, and discount placeholders should point at the seeded spec code. I’m applying those, then I’ll write the Suite 8 tests.


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


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

aggregated_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: 47fe4c
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


packages: livewire/flux livewire/livewire

queries:
input placeholder error component
button submit form component


server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Wire Submit
Wire Submit | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-submit

Livewire makes it easy to handle form submissions via the `wire:submit` directive. By adding `wire:submit` to a `<form>` element, Livewire will intercept the form submission, prevent the default browser handling, and call any Livewire component method.

Here's a basic example of using `wire:submit` to handle a "Create Post" form submission:

```php
<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\Post;

class CreatePost extends Component
{
    public $title = '';

    public $content = '';

    public function save()
    {
        Post::create([
            'title' => $this->title,
            'content' => $this->content,
        ]);

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

    public function render()
    {
        return view('livewire.create-post');
    }
}
```

```blade
<form wire:submit="save"> <!-- [tl! highlight] -->
    <input type="text" wire:model="title">

    <textarea wire:model="content"></textarea>

    <button type="submit">Save</button>
</form>
```

In the above example, when a user submits the form by clicking "Save", `wire:submit` intercepts the `submit` event and calls the `save()` action on the server.

> [!info] Livewire automatically calls `preventDefault()`
> `wire:submit` is different than other Livewire event handlers in that it internally calls `event.preventDefault()` without the need for the `.prevent` modifier. This is because there are very few instances you would be listening for the `submit` event and NOT want to prevent it's default browser handling (performing a full form submission to an endpoint).

> [!info] Livewire automatically disables forms while submitting
> By default, when Livewire is sending a form submission to the server, it will disable form submit buttons and mark all form inputs as `readonly`. This way a user cannot submit the same form again until the initial submission is complete.

---

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

flux:input

<ui-table-scroll-area>

Prop

Description

    wire:model

Binds the input to a Livewire property. See the wire:model documentation for more information.

    label

Label text displayed above the input. When provided, wraps the input in a flux:field component with an adjacent flux:label component. See the field component.

    description

Help text displayed above the input. When provided alongside label, appears between the label and input within the flux:field wrapper. See the field component.

    description:trailing

Help text displayed below the input. When provided alongside label, appears below the label and input within the flux:field wrapper. See the field component.

    placeholder

Placeholder text displayed when the input is empty.

    size

Size of the input. Options: sm, xs.

    variant

Visual style variant. Options: filled. Default: outline.

    disabled

Prevents user interaction with the input.

    readonly

Makes the input read-only.

    invalid

Applies error styling to the input.

    multiple

For file inputs, allows selecting multiple files.

    mask

Input mask pattern using Alpine's mask plugin. Example: 99/99/9999.

    mask:dynamic

Dynamic input mask pattern using Alpine's mask plugin. Example: $money($input).

    icon

Name of the icon displayed at the start of the input.

    icon:trailing

Name of the icon displayed at the end of the input.

    kbd

Keyboard shortcut hint displayed at the end of the input.

    clearable

If true, displays a clear button when the input has content.

    copyable

If true, displays a copy button to copy the input's content (https only).

    viewable

For password inputs, displays a toggle to show/hide the password.

    as

Render the input as a different element. Options: button. Default: input.

    input:class

CSS classes applied directly to the input element instead of the wrapper.

</ui-table-scroll-area>

<ui-table-scroll-area>

Slot

Description

    icon

Custom content displayed at the start of the input (e.g., icons).

    icon:leading

Custom content displayed at the start of the input (e.g., icons).

    icon:trailing

Custom content displayed at the end of the input (e.g., buttons).

</ui-table-scroll-area>

<ui-table-scroll-area>

Attribute

Description

    data-flux-input

Applied to the root element for styling and identification.

</ui-table-scroll-area>

_[Truncated]_