Output:
Chunk ID: 463e41
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 6350
Output:
diff --git a/specs/progress.md b/specs/progress.md
index 6a908f24..d9de012d 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -33,7 +33,7 @@ ## Acceptance Checklist
 | Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `OrderExportService`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, storefront cart stock-limit feedback for deny-policy inventory, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, customer address book management, customer logout session invalidation, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
 | Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout/reset, customer guard/provider, customer login/register/logout, customer password reset GET/POST routes at `/forgot-password` and `/reset-password/{token}` with store-scoped hashed tokens, reset-link throttling, generic reset responses, login rate limiting, session hardening, concrete resource policies, encrypted app client/webhook secrets, one-time display developer token generation, and a compatible hashed bearer-token admin API middleware with store/ability enforcement are implemented. |
 | Seed/test data | `specs/07-SEEDERS-AND-TEST-DATA.md` | partial | Seeders create Acme Fashion and Acme Electronics stores/domains/settings/admin access with checkout and notification defaults, 2 apps, 4 app installations, 4 OAuth clients, 4 OAuth tokens, 8 webhook subscriptions, 2 search settings rows, 25 indexed products, 60 analytics_daily rows, 210 analytics_events rows, 6 collections, 127 variants, 127 inventory rows, 206 variant option pivots, 2 themes, 6 theme files with local disk contents and metadata, 2 theme settings rows, 6 pages, 4 navigation menus, 21 navigation items including 6 child items, 2 tax settings rows, 2 shipping zones, 6 shipping rates, 6 discounts, the seeded customer, 1 default seeded customer address, and no product media/runtime carts. Order/payment seed data is still missing. |
-| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser coverage now checks storefront core pages, storefront account auth/reset pages including the customer root reset paths and account aliases, authenticated admin navigation/pages/settings/theme/search/apps/developers surfaces, Suite 2 admin authentication login/validation/redirect/logout/sidebar navigation interactions, Suite 3 admin product listing/create/edit/archive/search/status-filter/draft visibility interactions, Suite 4 admin order listing/filtering/detail/timeline/fulfillment/refund/bank-payment/guard/shipment interactions, Suite 5 admin discount seeded listing/percent/fixed/free-shipping creation/edit/status interactions, Suite 6 admin general/domain/shipping/tax settings interactions, Suite 7 storefront browsing, Suite 8 cart flow, Suite 9 checkout flow, Suite 10 customer account registration/login/orders/addresses/logout, Suite 11 inventory policy enforcement, Suite 12 tenant isolation, Suite 13 responsive/mobile flows, Suite 14 accessibility checks, Suite 15 admin collection listing/create/edit interactions, and mobile storefront rendering for JavaScript errors. The remaining Spec 08 interaction/permutation browser suites are still incomplete. |
+| Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing on `shop.test` and `acme-fashion.test`, product detail, add-to-cart, cart drawer/page, checkout through order completion, order confirmation, customer order list/detail, SQLite-backed search and search API JSON, analytics event ingestion API, DB-backed content pages, seeded nested navigation dropdowns, admin dashboard on desktop/mobile, admin analytics, admin app list/detail, admin developers token/webhook flows, admin product/collection/inventory/order/customer/discount/content/navigation/settings/theme/search pages, admin checkout/notification settings pages, admin product media section rendering, admin analytics CSV export, admin customer address creation, admin discount creation/editing/filtering, admin page creation, admin nested navigation rendering/item creation/save, admin theme editor preview, admin theme file list/editor, admin search reindexing, admin fulfillment creation/shipping/delivery, and auth flows without console warnings/errors on successful pages. Automated Pest browser coverage now checks storefront core pages, storefront account auth/reset pages including the customer root reset paths and account aliases, authenticated admin navigation/pages/settings/theme/search/apps/developers surfaces, Suite 2 admin authentication login/validation/redirect/logout/sidebar navigation interactions, Suite 3 admin product listing/create/edit/archive/search/status-filter/draft visibility interactions, Suite 4 admin order listing/filtering/detail/timeline/fulfillment/refund/bank-payment/guard/shipment interactions, Suite 5 admin discount seeded listing/percent/fixed/free-shipping creation/edit/status interactions, Suite 6 admin general/domain/shipping/tax settings interactions, Suite 7 storefront browsing, Suite 8 cart flow, Suite 9 checkout flow, Suite 10 customer account registration/login/orders/addresses/logout, Suite 11 inventory policy enforcement, Suite 12 tenant isolation, Suite 13 responsive/mobile flows, Suite 14 accessibility checks, Suite 15 admin collection listing/create/edit interactions, Suite 16 admin customer listing/detail/order-history/address interactions, and mobile storefront rendering for JavaScript errors. The remaining Spec 08 interaction/permutation browser suites are still incomplete. |
 | Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through `payment_selected`, the Phase 4 cart/checkout REST API surface, cart-page estimates, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, Phase 5 storefront order completion/customer order surfaces, Phase 5 admin order management, Phase 5 order API surfaces, Phase 6 admin dashboard/customer/discount/content/settings/theme/navigation management, Phase 7 search, Phase 7 analytics, and Phase 7 apps/webhooks are implemented. Final verification and completion audit are next. |

 ## Verification Evidence
@@ -530,6 +530,14 @@ ## Verification Evidence
 - 2026-05-04: `php artisan test --compact tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 34 tests, 330 assertions.
 - 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 15: 128 tests, 862 assertions.
 - 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 15: 377 tests, 2301 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Pest browser testing, Livewire pagination/relationship docs, Flux/Blade attribute docs, and Laravel relationship docs before adding Suite 16 admin customer-management browser coverage.
+- 2026-05-04: `php artisan make:test --pest Browser/Admin/CustomerManagementTest --no-interaction` created the Suite 16 customer-management browser test, which was moved into the Spec 08 browser directory structure.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding Suite 16.
+- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CustomerManagementTest.php` passed for the new Suite 16 admin customer-management browser coverage: 3 tests, 23 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Admin/CustomerManagementTest.php tests/Feature/Storefront/CustomerAccountTest.php tests/Feature/Storefront/OrderViewsTest.php` passed after the customer browser slice: 14 tests, 79 assertions.
+- 2026-05-04: `php artisan test --compact tests/Browser/Admin/CustomerManagementTest.php tests/Browser/Admin/CollectionManagementTest.php tests/Browser/Admin/SettingsTest.php tests/Browser/Admin/DiscountManagementTest.php tests/Browser/Admin/ProductManagementTest.php tests/Browser/Admin/OrderManagementTest.php` passed the combined admin management browser subset: 37 tests, 353 assertions.
+- 2026-05-04: `php artisan test --compact tests/Browser` passed after adding Suite 16: 131 tests, 885 assertions.
+- 2026-05-04: `php -d memory_limit=512M vendor/bin/pest --compact` passed the full suite after Suite 16: 380 tests, 2324 assertions.

 ## Decisions

@@ -599,13 +607,14 @@ ## Decisions
 - Product media uploads use Livewire temporary file uploads on the existing product form, create `ProductMedia` rows in `processing` status, and dispatch the existing `ProcessMediaUpload` job instead of adding a second media-processing path.
 - Resource policies now type concrete model parameters for Product, Collection, Customer, Discount, Order, Fulfillment, Refund, Page, and Theme actions; `ChecksStoreRole::storeIdForModel()` stays generic because it is the shared store-id extraction helper.
 - Laravel's SQLite `enum()` migrations generate database `CHECK` clauses in this app; `DatabaseConstraintTest` locks representative constraints across tenancy, roles, catalog, orders, tax settings, and exports.
-- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions, Suite 3 admin product-management interactions, Suite 4 admin order-management interactions, Suite 5 admin discount-management interactions, Suite 6 admin settings interactions, Suite 7 storefront browsing interactions, Suite 8 cart-flow interactions, Suite 9 checkout-flow interactions, Suite 10 customer-account interactions, Suite 11 inventory-policy interactions, Suite 12 tenant-isolation interactions, Suite 13 responsive/mobile interactions, Suite 14 accessibility interactions, and Suite 15 admin collection-management interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, admin product listing/create/edit/archive/search/status-filter/draft visibility guards, admin order listing/filtering/detail/timeline/customer info, fulfillment creation, refund processing, bank-transfer payment confirmation, unpaid fulfillment guard, shipped/delivered fulfillment transitions, admin discount seeded-code listing, percent/fixed/free-shipping discount creation, discount value editing, status badges, admin store settings persistence, domain listing, seeded shipping zone/rate display, shipping rate creation, tax settings display and prices-include-tax persistence, admin collection seeded-listing, collection creation, collection description editing, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product storefront visibility guards, stock/backorder messaging, main-navigation clicks, add-to-cart, cart quantity updates/removal, multi-product carts, subtotal/total display, valid/invalid/expired/maxed/free-shipping/fixed discount code handling, domestic/international checkout shipping, checkout discount carryover, address validation, empty-cart checkout protection, credit-card/PayPal/bank-transfer order completion, payment decline handling, payment-method UI switching, customer registration validation, customer login validation, account auth redirects, customer order history/detail, address book create/update, customer logout, deny-policy sold-out blocking, continue-policy backorder add-to-cart, in-stock status display, deny-policy cart stock-limit feedback, storefront product/collection/search store isolation, admin product/order store isolation, customer account store isolation, mobile hamburger navigation, mobile product/cart/checkout flows, mobile collection filtering, tablet admin login/sidebar navigation, console-clean home/cart pages, heading hierarchy, form labels, product image placeholder accessible text, checkout error linkage, keyboard navigation, and search input labeling.
+- Automated browser coverage now combines smoke coverage with Suite 2 admin-auth interactions, Suite 3 admin product-management interactions, Suite 4 admin order-management interactions, Suite 5 admin discount-management interactions, Suite 6 admin settings interactions, Suite 7 storefront browsing interactions, Suite 8 cart-flow interactions, Suite 9 checkout-flow interactions, Suite 10 customer-account interactions, Suite 11 inventory-policy interactions, Suite 12 tenant-isolation interactions, Suite 13 responsive/mobile interactions, Suite 14 accessibility interactions, Suite 15 admin collection-management interactions, and Suite 16 admin customer-management interactions: storefront catalog/content/search/cart, storefront account auth/reset pages, the full authenticated admin navigation surface, admin login validation, protected-route redirects, logout, sidebar navigation, admin product listing/create/edit/archive/search/status-filter/draft visibility guards, admin order listing/filtering/detail/timeline/customer info, fulfillment creation, refund processing, bank-transfer payment confirmation, unpaid fulfillment guard, shipped/delivered fulfillment transitions, admin discount seeded-code listing, percent/fixed/free-shipping discount creation, discount value editing, status badges, admin store settings persistence, domain listing, seeded shipping zone/rate display, shipping rate creation, tax settings display and prices-include-tax persistence, admin collection seeded-listing, collection creation, collection description editing, admin customer listing, customer detail, customer order history, customer address section rendering, mobile storefront rendering, seeded collection/product/search/static page browsing, sale/compare-at pricing, draft-product storefront visibility guards, stock/backorder messaging, main-navigation clicks, add-to-cart, cart quantity updates/removal, multi-product carts, subtotal/total display, valid/invalid/expired/maxed/free-shipping/fixed discount code handling, domestic/international checkout shipping, checkout discount carryover, address validation, empty-cart checkout protection, credit-card/PayPal/bank-transfer order completion, payment decline handling, payment-method UI switching, customer registration validation, customer login validation, account auth redirects, customer order history/detail, address book create/update, customer logout, deny-policy sold-out blocking, continue-policy backorder add-to-cart, in-stock status display, deny-policy cart stock-limit feedback, storefront product/collection/search store isolation, admin product/order store isolation, customer account store isolation, mobile hamburger navigation, mobile product/cart/checkout flows, mobile collection filtering, tablet admin login/sidebar navigation, console-clean home/cart pages, heading hierarchy, form labels, product image placeholder accessible text, checkout error linkage, keyboard navigation, and search input labeling.
 - The admin product-management browser suite uses Laravel `actingAs()` and `withSession()` to create deterministic authenticated browser visits; the separate Suite 2 admin-auth suite owns login-form coverage so persisted browser cookies from earlier tests cannot leak into product CRUD assertions.
 - The admin order-management browser suite creates deterministic per-test `#1001`, `#1002`, and `#1005` order fixtures because order/payment seed data is still deferred; the browser suite covers the spec interactions without changing the global seed contract.
 - Admin order detail now shows visible action feedback, a simple event timeline, and an explicit unpaid-fulfillment guard. Refund and fulfillment buttons are only exposed when the current financial/fulfillment state allows the backing service action.
 - The admin discount-management browser suite uses deterministic authenticated browser visits like the product/order management suites; Suite 2 remains the only browser suite responsible for login-form behavior, while Suite 5 covers discount listing, three value-type create flows, editing, and status badges.
 - The admin settings browser suite keeps domains on the general settings page because the implemented UI uses a domains section rather than a separate domains tab; Suite 6 still covers the spec-visible domain list plus general, shipping, and tax settings flows.
 - The admin collection-management browser suite uses deterministic authenticated browser visits and visible save feedback. The product active-status browser assertion searches within the active filter because the seeded active product list is paginated and individual products are not guaranteed to stay on page 1 as product fixtures evolve.
+- The admin customer-management browser suite uses deterministic authenticated browser visits and creates a per-test `#1001` order for `customer@acme.test` because global order/payment seed data is still deferred.
 - Pest's per-visit browser `host` option is temporary, so host-sensitive storefront browser tests set the Playwright host for the whole test lifecycle to keep clicked absolute URLs resolving through `shop.test`.
 - The discount seeder now includes the Spec 07/08 codes `WELCOME10`, `FLAT5`, `FREESHIP`, `EXPIRED20`, and `MAXED` while preserving the earlier implementation/test fixture codes `SAVE10` and `5OFF`.
 - Expired discount codes return an expired validation message before the generic non-active status message so storefront/cart validation can present the expected customer-facing error.
@@ -618,9 +627,9 @@ ## Decisions

 ## Open Issues

-- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage, Suite 2 admin authentication, Suite 3 admin product management, Suite 4 admin order management, Suite 5 admin discount management, Suite 6 admin settings, Suite 7 storefront browsing, Suite 8 cart flow, Suite 9 checkout flow, Suite 10 customer account flow, Suite 11 inventory enforcement, Suite 12 tenant isolation, Suite 13 responsive/mobile, Suite 14 accessibility, and Suite 15 admin collections.
+- Full automated browser interaction/permutation suites from Spec 08 are still incomplete beyond the expanded Pest browser smoke coverage, Suite 2 admin authentication, Suite 3 admin product management, Suite 4 admin order management, Suite 5 admin discount management, Suite 6 admin settings, Suite 7 storefront browsing, Suite 8 cart flow, Suite 9 checkout flow, Suite 10 customer account flow, Suite 11 inventory enforcement, Suite 12 tenant isolation, Suite 13 responsive/mobile, Suite 14 accessibility, Suite 15 admin collections, and Suite 16 admin customer management.
 - The full suite now needs a higher PHP memory limit when all browser suites run in one process; the default `php artisan test --compact` command hits 128 MB in Pest's browser WebSocket client, while `php -d memory_limit=512M vendor/bin/pest --compact` passes.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, Suite 3 admin product-management browser coverage, Suite 4 admin order-management browser coverage, Suite 5 admin discount-management browser coverage, Suite 6 admin settings browser coverage, Suite 7 storefront browsing browser coverage, Suite 8 cart-flow browser coverage, Suite 9 checkout-flow browser coverage, Suite 10 customer-account browser coverage, Suite 11 inventory-enforcement browser coverage, Suite 12 tenant-isolation browser coverage, Suite 13 responsive/mobile browser coverage, Suite 14 accessibility browser coverage, Suite 15 admin collection-management browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, deferred OAuth/app route stubs, expanded automated browser smoke coverage, Suite 2 admin authentication browser coverage, Suite 3 admin product-management browser coverage, Suite 4 admin order-management browser coverage, Suite 5 admin discount-management browser coverage, Suite 6 admin settings browser coverage, Suite 7 storefront browsing browser coverage, Suite 8 cart-flow browser coverage, Suite 9 checkout-flow browser coverage, Suite 10 customer-account browser coverage, Suite 11 inventory-enforcement browser coverage, Suite 12 tenant-isolation browser coverage, Suite 13 responsive/mobile browser coverage, Suite 14 accessibility browser coverage, Suite 15 admin collection-management browser coverage, Suite 16 admin customer-management browser coverage, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented, with the remaining full browser-suite gap tracked above.


 M specs/progress.md
?? tests/Browser/Admin/CustomerManagementTest.php


Output:
Chunk ID: 689d63
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 18
Output:
 M specs/progress.md
?? tests/Browser/Admin/CustomerManagementTest.php


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


sed -n '1,260p' tests/Browser/Admin/CustomerManagementTest.php


sed -n '1,260p' tests/Browser/Admin/CustomerManagementTest.php

<?php

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function adminCustomerBrowserHost(): array
{
    return ['host' => 'shop.test'];
}

function adminCustomerBrowserAuthenticate(mixed $testCase): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

    $testCase->actingAs($user);
    $testCase->withSession(['current_store_id' => $store->getKey()]);

    return $store;
}

function adminCustomerBrowserCustomer(Store $store): Customer
{
    return Customer::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('email', 'customer@acme.test')
        ->firstOrFail();
}

function adminCustomerBrowserCreateOrder(Store $store, Customer $customer): Order
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    $shipping = 499;
    $total = $variant->price_amount + $shipping;

    $order = Order::factory()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#1001',
        'payment_method' => PaymentMethod::CreditCard,
        'status' => OrderStatus::Paid,
        'financial_status' => FinancialStatus::Paid,
        'fulfillment_status' => FulfillmentStatus::Unfulfilled,
        'currency' => $store->default_currency,
        'subtotal_amount' => $variant->price_amount,
        'discount_amount' => 0,
        'shipping_amount' => $shipping,
        'tax_amount' => 0,
        'total_amount' => $total,
        'email' => $customer->email,
        'placed_at' => now()->subDay(),
    ]);

    OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'product_id' => $product->getKey(),
        'variant_id' => $variant->getKey(),
        'title_snapshot' => 'Classic Cotton T-Shirt',
        'sku_snapshot' => $variant->sku,
        'quantity' => 1,
        'unit_price_amount' => $variant->price_amount,
        'total_amount' => $variant->price_amount,
    ]);

    Payment::factory()->create([
        'order_id' => $order->getKey(),
        'method' => PaymentMethod::CreditCard,
        'status' => PaymentStatus::Captured,
        'amount' => $total,
        'currency' => $store->default_currency,
    ]);

    return $order->refresh();
}

function adminCustomerBrowserOpenCustomers(mixed $testCase): mixed
{
    adminCustomerBrowserAuthenticate($testCase);

    return visit('/admin/customers', adminCustomerBrowserHost())
        ->wait(1)
        ->assertPathIs('/admin/customers')
        ->assertSee('Customers')
        ->assertNoJavaScriptErrors();
}

function adminCustomerBrowserOpenCustomer(mixed $testCase): mixed
{
    return adminCustomerBrowserOpenCustomers($testCase)
        ->assertSee('John Doe')
        ->click('a:has-text("John Doe")')
        ->wait(1)
        ->assertPathContains('/admin/customers/')
        ->assertNoJavaScriptErrors();
}

test('shows the customer list', function (): void {
    adminCustomerBrowserOpenCustomers($this)
        ->assertSee('customer@acme.test')
        ->assertSee('John Doe')
        ->assertNoJavaScriptErrors();
});

test('shows customer detail with order history', function (): void {
    $store = adminCustomerBrowserAuthenticate($this);
    $customer = adminCustomerBrowserCustomer($store);
    adminCustomerBrowserCreateOrder($store, $customer);

    visit('/admin/customers', adminCustomerBrowserHost())
        ->wait(1)
        ->assertPathIs('/admin/customers')
        ->click('a:has-text("John Doe")')
        ->wait(1)
        ->assertPathContains('/admin/customers/')
        ->assertSee('John Doe')
        ->assertSee('customer@acme.test')
        ->assertSee('Order history')
        ->assertSee('#1001')
        ->assertNoJavaScriptErrors();
});

test('shows customer addresses', function (): void {
    adminCustomerBrowserOpenCustomer($this)
        ->assertSee('John Doe')
        ->assertSee('Addresses')
        ->assertSee('Home')
        ->assertNoJavaScriptErrors();
});


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

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Pest\Browser\Playwright\Playwright;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Playwright::setHost('shop.test');

    $this->seed(DatabaseSeeder::class);
});

afterEach(function (): void {
    Playwright::setHost(null);
});

function adminCustomerBrowserHost(): array
{
    return ['host' => 'shop.test'];
}

function adminCustomerBrowserAuthenticate(mixed $testCase): Store
{
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

    $testCase->actingAs($user);
    $testCase->withSession(['current_store_id' => $store->getKey()]);

    return $store;
}

function adminCustomerBrowserCustomer(Store $store): Customer
{
    return Customer::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('email', 'customer@acme.test')
        ->firstOrFail();
}

function adminCustomerBrowserCreateOrder(Store $store, Customer $customer): Order
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    $shipping = 499;
    $total = $variant->price_amount + $shipping;

    $order = Order::factory()->create([
        'store_id' => $store->getKey(),
        'customer_id' => $customer->getKey(),
        'order_number' => '#1001',
        'payment_method' => PaymentMethod::CreditCard,
        'status' => OrderStatus::Paid,
        'financial_status' => FinancialStatus::Paid,
        'fulfillment_status' => FulfillmentStatus::Unfulfilled,
        'currency' => $store->default_currency,
        'subtotal_amount' => $variant->price_amount,
        'discount_amount' => 0,
        'shipping_amount' => $shipping,
        'tax_amount' => 0,
        'total_amount' => $total,
        'email' => $customer->email,
        'placed_at' => now()->subDay(),
    ]);

    OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'product_id' => $product->getKey(),
        'variant_id' => $variant->getKey(),
        'title_snapshot' => 'Classic Cotton T-Shirt',
        'sku_snapshot' => $variant->sku,
        'quantity' => 1,
        'unit_price_amount' => $variant->price_amount,
        'total_amount' => $variant->price_amount,
    ]);

    Payment::factory()->create([
        'order_id' => $order->getKey(),
        'method' => PaymentMethod::CreditCard,
        'status' => PaymentStatus::Captured,
        'amount' => $total,
        'currency' => $store->default_currency,
    ]);

    return $order->refresh();
}

function adminCustomerBrowserOpenCustomers(mixed $testCase): mixed
{
    adminCustomerBrowserAuthenticate($testCase);

    return visit('/admin/customers', adminCustomerBrowserHost())
        ->wait(1)
        ->assertPathIs('/admin/customers')
        ->assertSee('Customers')
        ->assertNoJavaScriptErrors();
}

function adminCustomerBrowserOpenCustomer(mixed $testCase): mixed
{
    return adminCustomerBrowserOpenCustomers($testCase)
        ->assertSee('John Doe')
        ->click('a:has-text("John Doe")')
        ->wait(1)
        ->assertPathContains('/admin/customers/')
        ->assertNoJavaScriptErrors();
}

test('shows the customer list', function (): void {
    adminCustomerBrowserOpenCustomers($this)
        ->assertSee('customer@acme.test')
        ->assertSee('John Doe')
        ->assertNoJavaScriptErrors();
});

test('shows customer detail with order history', function (): void {
    $store = adminCustomerBrowserAuthenticate($this);
    $customer = adminCustomerBrowserCustomer($store);
    adminCustomerBrowserCreateOrder($store, $customer);

    visit('/admin/customers', adminCustomerBrowserHost())
        ->wait(1)
        ->assertPathIs('/admin/customers')
        ->click('a:has-text("John Doe")')
        ->wait(1)
        ->assertPathContains('/admin/customers/')
        ->assertSee('John Doe')
        ->assertSee('customer@acme.test')
        ->assertSee('Order history')
        ->assertSee('#1001')
        ->assertNoJavaScriptErrors();
});

test('shows customer addresses', function (): void {
    adminCustomerBrowserOpenCustomer($this)
        ->assertSee('John Doe')
        ->assertSee('Addresses')
        ->assertSee('Home')
        ->assertNoJavaScriptErrors();
});


git diff --check -- specs/progress.md && git diff --check --cached


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


The final diff is clean and there are no untracked Playwright artifacts. I’m staging the new Suite 16 test and progress update, then committing this slice.


git add tests/Browser/Admin/CustomerManagementTest.php specs/progress.md && git diff --cached --stat


git add tests/Browser/Admin/CustomerManagementTest.php specs/progress.md && git diff --cached --stat

 specs/progress.md                              |  17 ++-
 tests/Browser/Admin/CustomerManagementTest.php | 160 +++++++++++++++++++++++++
 2 files changed, 173 insertions(+), 4 deletions(-)


Output:
Chunk ID: 115f39
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 48
Output:
 specs/progress.md                              |  17 ++-
 tests/Browser/Admin/CustomerManagementTest.php | 160 +++++++++++++++++++++++++
 2 files changed, 173 insertions(+), 4 deletions(-)


git commit -m "Add admin customer browser suite"


git commit -m "Add admin customer browser suite"

[2026-05-04-codex-5-5-goal c793724e] Add admin customer browser suite
 2 files changed, 173 insertions(+), 4 deletions(-)
 create mode 100644 tests/Browser/Admin/CustomerManagementTest.php


Output:
Chunk ID: 513dba
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 48
Output:
[2026-05-04-codex-5-5-goal c793724e] Add admin customer browser suite
 2 files changed, 173 insertions(+), 4 deletions(-)
 create mode 100644 tests/Browser/Admin/CustomerManagementTest.php


c793724e Add admin customer browser suite


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


Output:
Chunk ID: 1b054f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 11
Output:
c793724e Add admin customer browser suite


Inspect Suite 16 admin customer requirements and current customer UI

Add/fix Suite 16 browser coverage and deterministic customer order fixture

Run focused and full verification for Suite 16

Explanation: Suite 16 is committed as c793724e with the browser file, progress updates, and passing focused, related feature, browser-directory, and full-suite verification.


Output: Plan updated


Suite 16 is committed as `c793724e Add admin customer browser suite`; worktree is clean. I’m moving on to the next Spec 08 suite rather than stopping here.


sed -n '2990,3068p' specs/08-PLAYWRIGHT-E2E-PLAN.md


sed -n '2990,3068p' specs/08-PLAYWRIGHT-E2E-PLAN.md

## Suite 17: Admin Pages Management

**File:** `tests/Browser/Admin/PageManagementTest.php`

**Purpose:** Verify CMS page management in the admin panel.

---

### Test 17.1: Shows the pages list

**Preconditions:** Logged in as admin, seeded pages

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin (email: `admin@acme.test`, password: `password`)
3. Navigate to `/admin/pages`

**Expected Results:**
- Page displays "About"
- No JavaScript errors

---

### Test 17.2: Can create a new page

**Preconditions:** Logged in as admin

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin
3. Navigate to `/admin/pages`
4. Click "Create page"
5. Enter title: `FAQ`
6. Enter body: `Frequently asked questions content here.`
7. Click "Save"

**Expected Results:**
- Page displays "Page saved"
- No JavaScript errors

---

### Test 17.3: Can edit an existing page

**Preconditions:** Logged in as admin, About page exists

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin
3. Navigate to `/admin/pages`
4. Click "About"
5. Update body to: `Updated about page content.`
6. Click "Save"

**Expected Results:**
- Page displays "Page saved"
- No JavaScript errors

**Total: 3 tests**

---

## Suite 18: Admin Analytics Dashboard

**File:** `tests/Browser/Admin/AnalyticsTest.php`

**Purpose:** Verify the analytics dashboard renders with data and no errors.

### Analytics Dashboard Sections

The dashboard at `/admin/analytics` should display:

| Section | Content |
|---------|---------|
| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |
| Sales Chart | Time-series chart of revenue over a date range |
| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |

---


Output:
Chunk ID: 1a675c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 436
Output:
## Suite 17: Admin Pages Management

**File:** `tests/Browser/Admin/PageManagementTest.php`

**Purpose:** Verify CMS page management in the admin panel.

---

### Test 17.1: Shows the pages list

**Preconditions:** Logged in as admin, seeded pages

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin (email: `admin@acme.test`, password: `password`)
3. Navigate to `/admin/pages`

**Expected Results:**
- Page displays "About"
- No JavaScript errors

---

### Test 17.2: Can create a new page

**Preconditions:** Logged in as admin

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin
3. Navigate to `/admin/pages`
4. Click "Create page"
5. Enter title: `FAQ`
6. Enter body: `Frequently asked questions content here.`
7. Click "Save"

**Expected Results:**
- Page displays "Page saved"
- No JavaScript errors

---

### Test 17.3: Can edit an existing page

**Preconditions:** Logged in as admin, About page exists

**Steps:**
1. Navigate to `/admin/login`
2. Log in as admin
3. Navigate to `/admin/pages`
4. Click "About"
5. Update body to: `Updated about page content.`
6. Click "Save"

**Expected Results:**
- Page displays "Page saved"
- No JavaScript errors

**Total: 3 tests**

---

## Suite 18: Admin Analytics Dashboard

**File:** `tests/Browser/Admin/AnalyticsTest.php`

**Purpose:** Verify the analytics dashboard renders with data and no errors.

### Analytics Dashboard Sections

The dashboard at `/admin/analytics` should display:

| Section | Content |
|---------|---------|
| KPI Tiles | Orders count, Revenue total, Average Order Value, Visits count |
| Sales Chart | Time-series chart of revenue over a date range |
| Conversion Funnel | visits -> add_to_cart -> checkout_started -> checkout_completed |

---


Suite 17 is admin page management. I’m using the Laravel, Livewire, Flux UI, Tailwind, and Pest skills here because this touches Livewire admin CMS forms and browser tests.


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

browser testing form fill click assert javascript errors

livewire forms validation textarea input browser testing


# Search Results

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

## Form objects

As more properties and validation rules are added to a Livewire component, it can begin to feel too crowded. To alleviate this pain and also provide a helpful abstraction for code reuse, you can use Livewire's *Form Objects* to store your properties and validation rules.

Below is the same `CreatePost` example, but now the properties and rules have been extracted to a dedicated form object named `PostForm`:

```php
<?php

namespace App\Livewire\Forms;

use Livewire\Attributes\Validate;
use Livewire\Form;

class PostForm extends Form
{
    #[Validate('required|min:3')]
	public $title = '';

    #[Validate('required|min:3')]
    public $content = '';
}
```

The `PostForm` above can now be defined as a property on the `CreatePost` component:

```php
<?php

namespace App\Livewire;

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

class CreatePost extends Component
{
    public PostForm $form;

    public function save()
    {
		Post::create(
    		$this->form->all()
    	);

		return redirect()->to('/posts');
    }

    // ...
}
```

As you can see, instead of listing out each property individually, we can retrieve all the property values using the `->all()` method on the form object.

Also, when referencing the property names in the template, you must prepend `form.` to each instance:

```blade
<form wire:submit="save">
	<input type="text" wire:model="form.title">
    <div>@error('form.title') {{ $message }} @enderror</div>

	<textarea wire:model="form.content"></textarea>
    <div>@error('form.content') {{ $message }} @enderror</div>

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

When using form objects, `#[Validate]` attribute validation will be run every time a property is updated. However, if you disable this behavior by specifying `onUpdate: false` on the attribute, you can manually run a form object's validation using `$this->form->validate()`:

```php
public function save()
{
    Post::create(
        $this->form->validate()
    );

    return redirect()->to('/posts');
}
```

Form objects are a useful abstraction for most larger datasets and a variety of additional features that make them even more powerful. For more information, check out the comprehensive [form object documentation](/docs/4.x/forms#extracting-a-form-object).

---

## Actions Event listeners
Actions | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/actions

## Event listeners

Livewire supports a variety of event listeners, allowing you to respond to various types of user interactions:

| Listener        | Description                               |
|-----------------|-------------------------------------------|
| `wire:click`    | Triggered when an element is clicked      |
| `wire:submit`   | Triggered when a form is submitted        |
| `wire:keydown`  | Triggered when a key is pressed down      |
| `wire:keyup`  | Triggered when a key is released
| `wire:mouseenter`| Triggered when the mouse enters an element |
| `wire:*`| Whatever text follows `wire:` will be used as the event name of the listener |

Because the event name after `wire:` can be anything, Livewire supports any browser event you might need to listen for. For example, to listen for `transitionend`, you can use `wire:transitionend`.

### Listening for specific keys

You can use one of Livewire's convenient aliases to narrow down key press event listeners to a specific key or combination of keys.

For example, to perform a search when a user hits `Enter` after typing into a search box, you can use `wire:keydown.enter`:

```blade
<input wire:model="query" wire:keydown.enter="searchPosts">
```

You can chain more key aliases after the first to listen for combinations of keys. For example, if you would like to listen for the `Enter` key only while the `Shift` key is pressed, you may write the following:

```blade
<input wire:keydown.shift.enter="...">
```

Below is a list of all the available key modifiers:

| Modifier      | Key                          |
|---------------|------------------------------|
| `.shift`      | Shift                        |
| `.enter`      | Enter                        |
| `.space`      | Space                        |
| `.ctrl`       | Ctrl                         |
| `.cmd`        | Cmd                          |
| `.meta`       | Cmd on Mac, Windows key on Windows |
| `.alt`        | Alt                          |
| `.up`         | Up arrow                     |
| `.down`       | Down arrow                   |
| `.left`       | Left arrow                   |
| `.right`      | Right arrow                  |
| `.escape`     | Escape                       |
| `.tab`        | Tab                          |
| `.caps-lock`  | Caps Lock                    |
| `.equal`      | Equal, `=`                   |
| `.period`     | Period, `.`                  |
| `.slash`      | Forward Slash, `/`           |

### Event handler modifiers

Livewire also includes helpful modifiers to make common event-handling tasks trivial.

For example, if you need to call `event.preventDefault()` from inside an event listener, you can suffix the event name with `.prevent`:

```blade
<input wire:keydown.prevent="...">
```

Here is a full list of all the available event listener modifiers and their functions:

| Modifier         | Key                                                     |
|------------------|---------------------------------------------------------|
| `.prevent`       | Equivalent of calling `.preventDefault()`               |
| `.stop`          | Equivalent of calling `.stopPropagation()`              |
| `.window`        | Listens for event on the `window` object                 |
| `.outside`       | Only listens for clicks "outside" the element            |
| `.document`      | Listens for events on the `document` object              |
| `.once`          | Ensures the listener is only called once                 |
| `.debounce`      | Debounce the handler by 250ms as a default               |
| `.debounce.100ms`| Debounce the handler for a specific amount of time       |
| `.throttle`      | Throttle the handler to being called every 250ms at minimum |
| `.throttle.100ms`| Throttle the handler at a custom duration                |
| `.self`          | Only call listener if event originated on this element, not children |
| `.camel`         | Converts event name to camel case (`wire:custom-event` -> "customEvent") |
| `.dot`           | Converts event name to dot notation (`wire:custom-event` -> "custom.event") |
| `.passive`       | `wire:touchstart.passive` won't block scroll performance |
| `.capture`       | Listen for event in the "capturing" phase                 |

Because `wire:` uses [Alpine's](https://alpinejs.dev) `x-on` directive under the hood, these modifiers are made available to you by Alpine. For more context on when you should use these modifiers, consult the [Alpine Events documentation](https://alpinejs.dev/essentials/events).

### Handling third-party events

Livewire also supports listening for custom events fired by third-party libraries.

For example, let's imagine you're using the [Trix](https://trix-editor.org/) rich text editor in your project and you want to listen for the `trix-change` event to capture the editor's content. You can accomplish this using the `wire:trix-change` directive:

```blade
<form wire:submit="save">
    <!-- ... -->

    <trix-editor
        wire:trix-change="setPostContent($event.target.value)"
    ></trix-editor>

    <!-- ... -->
</form>
```

In this example, the `setPostContent` action is called whenever the `trix-change` event is triggered, updating the `content` property in the Livewire component with the current value of the Trix editor.

> [!info] You can access the event object using `$event`
> Within Livewire event handlers, you can access the event object via `$event`. This is useful for referencing information on the event. For example, you can access the element that triggered the event via `$event.target`.

> [!warning]
> The Trix demo code above is incomplete and only useful as a demonstration of event listeners. If used verbatim, a network request would be fired on every single keystroke. A more performant implementation would be:
>
> ```blade
> <trix-editor
>    x-on:trix-change="$wire.content = $event.target.value"
>></trix-editor>
> ```

### Listening for dispatched custom events

If your application dispatches custom events from Alpine, you can also listen for those using Livewire:

```blade
<div wire:custom-event="...">

    <!-- Deeply nested within this component: -->
    <button x-on:click="$dispatch('custom-event')">...</button>

</div>
```

When the button is clicked in the above example, the `custom-event` event is dispatched and bubbles up to the root of the Livewire component where `wire:custom-event` catches it and invokes a given action.

If you want to listen for an event dispatched somewhere else in your application, you will need to wait instead for the event to bubble up to the `window` object and listen for it there. Fortunately, Livewire makes this easy by allowing you to add a simple `.window` modifier to any event listener:

```blade
<div wire:custom-event.window="...">
    <!-- ... -->
</div>

<!-- Dispatched somewhere on the page outside the component: -->
<button x-on:click="$dispatch('custom-event')">...</button>
```

### Disabling inputs while a form is being submitted

Consider the `CreatePost` example we previously discussed:

```blade
<form wire:submit="save">
    <input wire:model="title">

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

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

When a user clicks "Save", a network request is sent to the server to call the `save()` action on the Livewire component.

But, let's imagine that a user is filling out this form on a slow internet connection. The user clicks "Save" and nothing happens initially because the network request takes longer than usual. They might wonder if the submission failed and attempt to click the "Save" button again while the first request is still being handled.

In this case, there would be two requests for the same action being processed at the same time.

To prevent this scenario, Livewire automatically disables the submit button and all form inputs inside the `<form>` element while a `wire:submit` action is being processed. This ensures that a form isn't accidentally submitted twice.

To further lessen the confusion for users on slower connections, it is often helpful to show some loading indicator such as a subtle background color change or SVG animation.

Livewire provides a `wire:loading` directive that makes it trivial to show and hide loading indicators anywhere on a page. Here's a short example of using `wire:loading` to show a loading message below the "Save" button:

```blade
<form wire:submit="save">
    <textarea wire:model="content"></textarea>

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

    <span wire:loading>Saving...</span> <!-- [tl! highlight] -->
</form>
```

Alternatively, you can style loading states directly using Tailwind and Livewire's automatic `data-loading` attribute:

```blade
<form wire:submit="save">
    <textarea wire:model="content"></textarea>

    <button type="submit" class="data-loading:opacity-50">Save</button>

    <span class="not-data-loading:hidden">Saving...</span>
</form>
```

For most cases, using `data-loading` selectors is simpler and more flexible than `wire:loading`. [Learn more about loading states →](/docs/4.x/loading-states)

---

## Wire Transition Browser support
Wire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition

## Browser support

View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.

> [!warning] Firefox has limited support
> Firefox 144+ supports basic view transitions, but does not support transition types.

[View browser support on caniuse.com →](https://caniuse.com/view-transitions)

---

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

    Textarea

Capture multi-line text input from users. Ideal for comments, descriptions, and feedback.

<flux:textarea />

---

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

## Browser testing

Pest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.

### Installing browser testing

First, install the Pest browser plugin:

```shell
composer require pestphp/pest-plugin-browser --dev
```

Next, install Playwright via npm:

```shell
npm install playwright@latest
npx playwright install
```

For complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).

### Writing browser tests

Instead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:

```php
it('can create a new post', function () {
    Livewire::visit('post.create')
        ->type('[wire\:model="title"]', 'My first post')
        ->type('[wire\:model="content"]', 'This is the content')
        ->press('Save')
        ->assertSee('Post created successfully');
});
```

Browser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.

For a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).

> [!info] When to use browser tests
> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.

---

## Attribute Js Basic usage
Attribute Js | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-js

## Basic usage

Apply the `#[Js]` attribute to methods that return JavaScript expressions:

```php
<?php // resources/views/components/post/⚡create.blade.php

use Livewire\Attributes\Js;
use Livewire\Component;

new class extends Component {
    public $title = '';
    public $content = '';

    #[Js] // [tl! highlight:start]
    public function resetForm()
    {
        return <<<'JS'
            $wire.title = ''
            $wire.content = ''
        JS;
    } // [tl! highlight:end]
};
```

```blade
<form wire:submit="save">
    <input wire:model="title" placeholder="Title">
    <textarea wire:model="content" placeholder="Content"></textarea>

    <button type="submit">Save</button>
    <button type="button" @click="$wire.resetForm()">Reset</button> <!-- [tl! highlight] -->
</form>
```

When `$wire.resetForm()` is called, the JavaScript executes directly in the browser — no server round-trip occurs.

---

## Understanding Nesting Performance implications
Understanding Nesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/understanding-nesting

## Performance implications

Livewire's independent component architecture can have both positive and negative implications for your application.

An advantage of this architecture is it allows you to isolate expensive portions of your application. For example, you can quarantine a slow database query to its own independent component, and its performance overhead won't impact the rest of the page.

However, the biggest drawback of this approach is that because components are entirely separate, inter-component communication/dependencies becomes more difficult.

For example, if you had a property passed down from the above parent `Posts` component to the nested `ShowPost` component, it wouldn't be "reactive". Because each component is independent, if a request to the parent component changed the value of the property being passed into `ShowPost`, it wouldn't update inside `ShowPost`.

Livewire has overcome a number of these hurdles and provides dedicated APIs for these scenarios like: [Reactive properties](/docs/4.x/nesting#reactive-props), [Modelable components](/docs/4.x/nesting#binding-to-child-data-using-wiremodel), and [the `$parent` object](/docs/4.x/nesting#directly-accessing-the-parent-from-the-child).

Armed with this knowledge of how nested Livewire components operate, you will be able to make more informed decisions about when and how to nest components within your application.

---

## Attribute Validate Example: Contact form
Attribute Validate | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-validate

## Example: Contact form

Here's a complete contact form with validation:

```php
<?php // resources/views/pages/⚡contact.blade.php

use Livewire\Attributes\Validate;
use Livewire\Component;
use App\Mail\ContactMessage;
use Illuminate\Support\Facades\Mail;

new class extends Component {
    #[Validate('required|min:2', as: 'name')]
    public $name = '';

    #[Validate('required|email')]
    public $email = '';

    #[Validate('required')]
    public $subject = '';

    #[Validate('required|min:10', as: 'message')]
    public $message = '';

    public function submit()
    {
        $validated = $this->validate();

        Mail::to('support@example.com')->send(new ContactMessage($validated));

        session()->flash('success', 'Message sent successfully!');

        $this->reset();
    }
};
?>

<div>
    @if (session('success'))
        <div class="alert">{{ session('success') }}</div>
    @endif

    <form wire:submit="submit">
        <div>
            <input type="text" wire:model.live.blur="name" placeholder="Your name">
            @error('name') <span class="error">{{ $message }}</span> @enderror
        </div>

        <div>
            <input type="email" wire:model.live.blur="email" placeholder="Your email">
            @error('email') <span class="error">{{ $message }}</span> @enderror
        </div>

        <div>
            <input type="text" wire:model.live.blur="subject" placeholder="Subject">
            @error('subject') <span class="error">{{ $message }}</span> @enderror
        </div>

        <div>
            <textarea wire:model.live.blur="message" placeholder="Your message"></textarea>
            @error('message') <span class="error">{{ $message }}</span> @enderror
        </div>

        <button type="submit">Send Message</button>
    </form>
</div>
```

Users get immediate feedback as they fill out the form, with friendly field names and helpful error messages.

---

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

### assertNoJavaScriptErrors

The `assertNoJavaScriptErrors` method asserts there are no JavaScript errors on the page:

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

---

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

_[Truncated]_


Output:
Wall time: 3.6035 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Validation Form objects\nValidation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation\n\n## Form objects\n\nAs more properties and validation rules are added to a Livewire component, it can begin to feel too crowded. To alleviate this pain and also provide a helpful abstraction for code reuse, you can use Livewire's *Form Objects* to store your properties and validation rules.\n\nBelow is the same `CreatePost` example, but now the properties and rules have been extracted to a dedicated form object named `PostForm`:\n\n```php\n<?php\n\nnamespace App\\Livewire\\Forms;\n\nuse Livewire\\Attributes\\Validate;\nuse Livewire\\Form;\n\nclass PostForm extends Form\n{\n    #[Validate('required|min:3')]\n\tpublic $title = '';\n\n    #[Validate('required|min:3')]\n    public $content = '';\n}\n```\n\nThe `PostForm` above can now be defined as a property on the `CreatePost` component:\n\n```php\n<?php\n\nnamespace App\\Livewire;\n\nuse App\\Livewire\\Forms\\PostForm;\nuse Livewire\\Component;\nuse App\\Models\\Post;\n\nclass CreatePost extends Component\n{\n    public PostForm $form;\n\n    public function save()\n    {\n\t\tPost::create(\n    \t\t$this->form->all()\n    \t);\n\n\t\treturn redirect()->to('/posts');\n    }\n\n    // ...\n}\n```\n\nAs you can see, instead of listing out each property individually, we can retrieve all the property values using the `->all()` method on the form object.\n\nAlso, when referencing the property names in the template, you must prepend `form.` to each instance:\n\n```blade\n<form wire:submit=\"save\">\n\t<input type=\"text\" wire:model=\"form.title\">\n    <div>@error('form.title') {{ $message }} @enderror</div>\n\n\t<textarea wire:model=\"form.content\"></textarea>\n    <div>@error('form.content') {{ $message }} @enderror</div>\n\n\t<button type=\"submit\">Save</button>\n</form>\n```\n\nWhen using form objects, `#[Validate]` attribute validation will be run every time a property is updated. However, if you disable this behavior by specifying `onUpdate: false` on the attribute, you can manually run a form object's validation using `$this->form->validate()`:\n\n```php\npublic function save()\n{\n    Post::create(\n        $this->form->validate()\n    );\n\n    return redirect()->to('/posts');\n}\n```\n\nForm objects are a useful abstraction for most larger datasets and a variety of additional features that make them even more powerful. For more information, check out the comprehensive [form object documentation](/docs/4.x/forms#extracting-a-form-object).\n\n---\n\n## Actions Event listeners\nActions | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/actions\n\n## Event listeners\n\nLivewire supports a variety of event listeners, allowing you to respond to various types of user interactions:\n\n| Listener        | Description                               |\n|-----------------|-------------------------------------------|\n| `wire:click`    | Triggered when an element is clicked      |\n| `wire:submit`   | Triggered when a form is submitted        |\n| `wire:keydown`  | Triggered when a key is pressed down      |\n| `wire:keyup`  | Triggered when a key is released\n| `wire:mouseenter`| Triggered when the mouse enters an element |\n| `wire:*`| Whatever text follows `wire:` will be used as the event name of the listener |\n\nBecause the event name after `wire:` can be anything, Livewire supports any browser event you might need to listen for. For example, to listen for `transitionend`, you can use `wire:transitionend`.\n\n### Listening for specific keys\n\nYou can use one of Livewire's convenient aliases to narrow down key press event listeners to a specific key or combination of keys.\n\nFor example, to perform a search when a user hits `Enter` after typing into a search box, you can use `wire:keydown.enter`:\n\n```blade\n<input wire:model=\"query\" wire:keydown.enter=\"searchPosts\">\n```\n\nYou can chain more key aliases after the first to listen for combinations of keys. For example, if you would like to listen for the `Enter` key only while the `Shift` key is pressed, you may write the following:\n\n```blade\n<input wire:keydown.shift.enter=\"...\">\n```\n\nBelow is a list of all the available key modifiers:\n\n| Modifier      | Key                          |\n|---------------|------------------------------|\n| `.shift`      | Shift                        |\n| `.enter`      | Enter                        |\n| `.space`      | Space                        |\n| `.ctrl`       | Ctrl                         |\n| `.cmd`        | Cmd                          |\n| `.meta`       | Cmd on Mac, Windows key on Windows |\n| `.alt`        | Alt                          |\n| `.up`         | Up arrow                     |\n| `.down`       | Down arrow                   |\n| `.left`       | Left arrow                   |\n| `.right`      | Right arrow                  |\n| `.escape`     | Escape                       |\n| `.tab`        | Tab                          |\n| `.caps-lock`  | Caps Lock                    |\n| `.equal`      | Equal, `=`                   |\n| `.period`     | Period, `.`                  |\n| `.slash`      | Forward Slash, `/`           |\n\n### Event handler modifiers\n\nLivewire also includes helpful modifiers to make common event-handling tasks trivial.\n\nFor example, if you need to call `event.preventDefault()` from inside an event listener, you can suffix the event name with `.prevent`:\n\n```blade\n<input wire:keydown.prevent=\"...\">\n```\n\nHere is a full list of all the available event listener modifiers and their functions:\n\n| Modifier         | Key                                                     |\n|------------------|---------------------------------------------------------|\n| `.prevent`       | Equivalent of calling `.preventDefault()`               |\n| `.stop`          | Equivalent of calling `.stopPropagation()`              |\n| `.window`        | Listens for event on the `window` object                 |\n| `.outside`       | Only listens for clicks \"outside\" the element            |\n| `.document`      | Listens for events on the `document` object              |\n| `.once`          | Ensures the listener is only called once                 |\n| `.debounce`      | Debounce the handler by 250ms as a default               |\n| `.debounce.100ms`| Debounce the handler for a specific amount of time       |\n| `.throttle`      | Throttle the handler to being called every 250ms at minimum |\n| `.throttle.100ms`| Throttle the handler at a custom duration                |\n| `.self`          | Only call listener if event originated on this element, not children |\n| `.camel`         | Converts event name to camel case (`wire:custom-event` -> \"customEvent\") |\n| `.dot`           | Converts event name to dot notation (`wire:custom-event` -> \"custom.event\") |\n| `.passive`       | `wire:touchstart.passive` won't block scroll performance |\n| `.capture`       | Listen for event in the \"capturing\" phase                 |\n\nBecause `wire:` uses [Alpine's](https://alpinejs.dev) `x-on` directive under the hood, these modifiers are made available to you by Alpine. For more context on when you should use these modifiers, consult the [Alpine Events documentation](https://alpinejs.dev/essentials/events).\n\n### Handling third-party events\n\nLivewire also supports listening for custom events fired by third-party libraries.\n\nFor example, let's imagine you're using the [Trix](https://trix-editor.org/) rich text editor in your project and you want to listen for the `trix-change` event to capture the editor's content. You can accomplish this using the `wire:trix-change` directive:\n\n```blade\n<form wire:submit=\"save\">\n    <!-- ... -->\n\n    <trix-editor\n        wire:trix-change=\"setPostContent($event.target.value)\"\n    ></trix-editor>\n\n    <!-- ... -->\n</form>\n```\n\nIn this example, the `setPostContent` action is called whenever the `trix-change` event is triggered, updating the `content` property in the Livewire component with the current value of the Trix editor.\n\n> [!info] You can access the event object using `$event`\n> Within Livewire event handlers, you can access the event object via `$event`. This is useful for referencing information on the event. For example, you can access the element that triggered the event via `$event.target`.\n\n> [!warning]\n> The Trix demo code above is incomplete and only useful as a demonstration of event listeners. If used verbatim, a network request would be fired on every single keystroke. A more performant implementation would be:\n>\n> ```blade\n> <trix-editor\n>    x-on:trix-change=\"$wire.content = $event.target.value\"\n>></trix-editor>\n> ```\n\n### Listening for dispatched custom events\n\nIf your application dispatches custom events from Alpine, you can also listen for those using Livewire:\n\n```blade\n<div wire:custom-event=\"...\">\n\n    <!-- Deeply nested within this component: -->\n    <button x-on:click=\"$dispatch('custom-event')\">...</button>\n\n</div>\n```\n\nWhen the button is clicked in the above example, the `custom-event` event is dispatched and bubbles up to the root of the Livewire component where `wire:custom-event` catches it and invokes a given action.\n\nIf you want to listen for an event dispatched somewhere else in your application, you will need to wait instead for the event to bubble up to the `window` object and listen for it there. Fortunately, Livewire makes this easy by allowing you to add a simple `.window` modifier to any event listener:\n\n```blade\n<div wire:custom-event.window=\"...\">\n    <!-- ... -->\n</div>\n\n<!-- Dispatched somewhere on the page outside the component: -->\n<button x-on:click=\"$dispatch('custom-event')\">...</button>\n```\n\n### Disabling inputs while a form is being submitted\n\nConsider the `CreatePost` example we previously discussed:\n\n```blade\n<form wire:submit=\"save\">\n    <input wire:model=\"title\">\n\n    <textarea wire:model=\"content\"></textarea>\n\n    <button type=\"submit\">Save</button>\n</form>\n```\n\nWhen a user clicks \"Save\", a network request is sent to the server to call the `save()` action on the Livewire component.\n\nBut, let's imagine that a user is filling out this form on a slow internet connection. The user clicks \"Save\" and nothing happens initially because the network request takes longer than usual. They might wonder if the submission failed and attempt to click the \"Save\" button again while the first request is still being handled.\n\nIn this case, there would be two requests for the same action being processed at the same time.\n\nTo prevent this scenario, Livewire automatically disables the submit button and all form inputs inside the `<form>` element while a `wire:submit` action is being processed. This ensures that a form isn't accidentally submitted twice.\n\nTo further lessen the confusion for users on slower connections, it is often helpful to show some loading indicator such as a subtle background color change or SVG animation.\n\nLivewire provides a `wire:loading` directive that makes it trivial to show and hide loading indicators anywhere on a page. Here's a short example of using `wire:loading` to show a loading message below the \"Save\" button:\n\n```blade\n<form wire:submit=\"save\">\n    <textarea wire:model=\"content\"></textarea>\n\n    <button type=\"submit\">Save</button>\n\n    <span wire:loading>Saving...</span> <!-- [tl! highlight] -->\n</form>\n```\n\nAlternatively, you can style loading states directly using Tailwind and Livewire's automatic `data-loading` attribute:\n\n```blade\n<form wire:submit=\"save\">\n    <textarea wire:model=\"content\"></textarea>\n\n    <button type=\"submit\" class=\"data-loading:opacity-50\">Save</button>\n\n    <span class=\"not-data-loading:hidden\">Saving...</span>\n</form>\n```\n\nFor most cases, using `data-loading` selectors is simpler and more flexible than `wire:loading`. [Learn more about loading states →](/docs/4.x/loading-states)\n\n---\n\n## Wire Transition Browser support\nWire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition\n\n## Browser support\n\nView Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.\n\n> [!warning] Firefox has limited support\n> Firefox 144+ supports basic view transitions, but does not support transition types.\n\n[View browser support on caniuse.com →](https://caniuse.com/view-transitions)\n\n---\n\n## Components Textarea > Textarea\nComponents Textarea | livewire/flux@2.x | Source: https://fluxui.dev/components/textarea#livewire/flux\n\n

\n    Textarea\n

Capture multi-line text input from users. Ideal for comments, descriptions, and feedback.

<flux:textarea />

\n\n---\n\n## Testing Browser testing\nTesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing\n\n## Browser testing\n\nPest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.\n\n### Installing browser testing\n\nFirst, install the Pest browser plugin:\n\n```shell\ncomposer require pestphp/pest-plugin-browser --dev\n```\n\nNext, install Playwright via npm:\n\n```shell\nnpm install playwright@latest\nnpx playwright install\n```\n\nFor complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).\n\n### Writing browser tests\n\nInstead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:\n\n```php\nit('can create a new post', function () {\n    Livewire::visit('post.create')\n        ->type('[wire\\:model=\"title\"]', 'My first post')\n        ->type('[wire\\:model=\"content\"]', 'This is the content')\n        ->press('Save')\n        ->assertSee('Post created successfully');\n});\n```\n\nBrowser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.\n\nFor a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).\n\n> [!info] When to use browser tests\n> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.\n\n---\n\n## Attribute Js Basic usage\nAttribute Js | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-js\n\n## Basic usage\n\nApply the `#[Js]` attribute to methods that return JavaScript expressions:\n\n```php\n<?php // resources/views/components/post/⚡create.blade.php\n\nuse Livewire\\Attributes\\Js;\nuse Livewire\\Component;\n\nnew class extends Component {\n    public $title = '';\n    public $content = '';\n\n    #[Js] // [tl! highlight:start]\n    public function resetForm()\n    {\n        return <<<'JS'\n            $wire.title = ''\n            $wire.content = ''\n        JS;\n    } // [tl! highlight:end]\n};\n```\n\n```blade\n<form wire:submit=\"save\">\n    <input wire:model=\"title\" placeholder=\"Title\">\n    <textarea wire:model=\"content\" placeholder=\"Content\"></textarea>\n\n    <button type=\"submit\">Save</button>\n    <button type=\"button\" @click=\"$wire.resetForm()\">Reset</button> <!-- [tl! highlight] -->\n</form>\n```\n\nWhen `$wire.resetForm()` is called, the JavaScript executes directly in the browser — no server round-trip occurs.\n\n---\n\n## Understanding Nesting Performance implications\nUnderstanding Nesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/understanding-nesting\n\n## Performance implications\n\nLivewire's independent component architecture can have both positive and negative implications for your application.\n\nAn advantage of this architecture is it allows you to isolate expensive portions of your application. For example, you can quarantine a slow database query to its own independent component, and its performance overhead won't impact the rest of the page.\n\nHowever, the biggest drawback of this approach is that because components are entirely separate, inter-component communication/dependencies becomes more difficult.\n\nFor example, if you had a property passed down from the above parent `Posts` component to the nested `ShowPost` component, it wouldn't be \"reactive\". Because each component is independent, if a request to the parent component changed the value of the property being passed into `ShowPost`, it wouldn't update inside `ShowPost`.\n\nLivewire has overcome a number of these hurdles and provides dedicated APIs for these scenarios like: [Reactive properties](/docs/4.x/nesting#reactive-props), [Modelable components](/docs/4.x/nesting#binding-to-child-data-using-wiremodel), and [the `$parent` object](/docs/4.x/nesting#directly-accessing-the-parent-from-the-child).\n\nArmed with this knowledge of how nested Livewire components operate, you will be able to make more informed decisions about when and how to nest components within your application.\n\n---\n\n## Attribute Validate Example: Contact form\nAttribute Validate | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-validate\n\n## Example: Contact form\n\nHere's a complete contact form with validation:\n\n```php\n<?php // resources/views/pages/⚡contact.blade.php\n\nuse Livewire\\Attributes\\Validate;\nuse Livewire\\Component;\nuse App\\Mail\\ContactMessage;\nuse Illuminate\\Support\\Facades\\Mail;\n\nnew class extends Component {\n    #[Validate('required|min:2', as: 'name')]\n    public $name = '';\n\n    #[Validate('required|email')]\n    public $email = '';\n\n    #[Validate('required')]\n    public $subject = '';\n\n    #[Validate('required|min:10', as: 'message')]\n    public $message = '';\n\n    public function submit()\n    {\n        $validated = $this->validate();\n\n        Mail::to('support@example.com')->send(new ContactMessage($validated));\n\n        session()->flash('success', 'Message sent successfully!');\n\n        $this->reset();\n    }\n};\n?>\n\n<div>\n    @if (session('success'))\n        <div class=\"alert\">{{ session('success') }}</div>\n    @endif\n\n    <form wire:submit=\"submit\">\n        <div>\n            <input type=\"text\" wire:model.live.blur=\"name\" placeholder=\"Your name\">\n            @error('name') <span class=\"error\">{{ $message }}</span> @enderror\n        </div>\n\n        <div>\n            <input type=\"email\" wire:model.live.blur=\"email\" placeholder=\"Your email\">\n            @error('email') <span class=\"error\">{{ $message }}</span> @enderror\n        </div>\n\n        <div>\n            <input type=\"text\" wire:model.live.blur=\"subject\" placeholder=\"Subject\">\n            @error('subject') <span class=\"error\">{{ $message }}</span> @enderror\n        </div>\n\n        <div>\n            <textarea wire:model.live.blur=\"message\" placeholder=\"Your message\"></textarea>\n            @error('message') <span class=\"error\">{{ $message }}</span> @enderror\n        </div>\n\n        <button type=\"submit\">Send Message</button>\n    </form>\n</div>\n```\n\nUsers get immediate feedback as they fill out the form, with friendly field names and helpful error messages.\n\n---\n\n## Browser Testing assertNoJavaScriptErrors\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### assertNoJavaScriptErrors\n\nThe `assertNoJavaScriptErrors` method asserts there are no JavaScript errors on the page:\n\n```php\n$page->assertNoJavaScriptErrors();\n```\n\n---\n\n## Wire Submit\nWire Submit | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-submit\n\nLivewire 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.\n\nHere's a basic example of using `wire:submit` to handle a \"Create Post\" form submission:\n\n```php\n<?php\n\nnamespace App\\Livewire;\n\nuse Livewire\\Component;\nuse App\\Models\\Post;\n\nclass CreatePost extends Component\n{\n    public $title = '';\n\n    public $content = '';\n\n    public function save()\n    {\n        Post::create([\n            'title' => $this->title,\n            'content' => $this->content,\n        ]);\n\n        $this->redirect('/posts');\n    }\n\n    public function render()\n    {\n        return view('livewire.create-post');\n    }\n}\n```\n\n```blade\n<form wire:submit=\"save\"> <!-- [tl! highlight] -->\n    <input type=\"text\" wire:model=\"title\">\n\n    <textarea wire:model=\"content\"></textarea>\n\n    <button type=\"submit\">Save</button>\n</form>\n```\n\nIn 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.\n\n> [!info] Livewire automatically calls `preventDefault()`\n> `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).\n\n> [!info] Livewire automatically disables forms while submitting\n> 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.\n\n_[Truncated]_"}]


Docs are checked. I’m reading the current page management components and feature tests to see whether the browser suite can be added without changing the UI.


sed -n '1,260p' app/Livewire/Admin/Pages/Index.php


sed -n '1,320p' app/Livewire/Admin/Pages/Form.php


sed -n '1,260p' resources/views/livewire/admin/pages/index.blade.php


sed -n '1,340p' resources/views/livewire/admin/pages/form.blade.php


sed -n '1,260p' tests/Feature/Admin/ContentManagementTest.php


rg -n "Create page|Page saved|About" database/seeders app resources tests specs/08-PLAYWRIGHT-E2E-PLAN.md -S


sed -n '1,260p' app/Livewire/Admin/Pages/Index.php

<?php

namespace App\Livewire\Admin\Pages;

use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use AuthorizesRequests;
    use WithPagination;

    #[Locked]
    public int $storeId;

    public string $search = '';

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Page::class);

        $this->storeId = $store->getKey();
    }

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function pages(): LengthAwarePaginator
    {
        return Page::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', $search)
                        ->orWhere('handle', 'like', $search);
                });
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate(20);
    }

    public function statusColor(PageStatus $status): string
    {
        return match ($status) {
            PageStatus::Published => 'green',
            PageStatus::Archived => 'red',
            default => 'zinc',
        };
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.index', [
            'pages' => $this->pages(),
        ])->layout('layouts.app', [
            'title' => __('Pages'),
        ]);
    }
}


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

namespace App\Livewire\Admin\Pages;

use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use AuthorizesRequests;
    use WithPagination;

    #[Locked]
    public int $storeId;

    public string $search = '';

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('viewAny', Page::class);

        $this->storeId = $store->getKey();
    }

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function pages(): LengthAwarePaginator
    {
        return Page::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', $search)
                        ->orWhere('handle', 'like', $search);
                });
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate(20);
    }

    public function statusColor(PageStatus $status): string
    {
        return match ($status) {
            PageStatus::Published => 'green',
            PageStatus::Archived => 'red',
            default => 'zinc',
        };
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.index', [
            'pages' => $this->pages(),
        ])->layout('layouts.app', [
            'title' => __('Pages'),
        ]);
    }
}


sed -n '1,320p' app/Livewire/Admin/Pages/Form.php

<?php

namespace App\Livewire\Admin\Pages;

use App\Enums\PageStatus;
use App\Models\NavigationMenu;
use App\Models\Page;
use App\Models\Store;
use App\Services\NavigationService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;

class Form extends Component
{
    use AuthorizesRequests;

    public ?Page $page = null;

    public string $title = '';

    public string $handle = '';

    public string $bodyHtml = '';

    public string $status = 'draft';

    public string $publishedAt = '';

    public function mount(?Page $page = null): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        if ($page?->exists) {
            abort_unless((int) $page->store_id === $store->getKey(), 404);

            $this->authorize('update', $page);

            $this->page = $page;
            $this->fillFromPage($page);

            return;
        }

        $this->authorize('create', Page::class);
    }

    public function updatedTitle(string $title): void
    {
        if ($this->page instanceof Page || $this->handle !== '') {
            return;
        }

        $this->handle = Str::slug($title);
    }

    public function save(NavigationService $navigation): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorizeSave();

        $this->handle = Str::slug($this->handle);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'handle' => [
                'required',
                'string',
                'max:255',
                'alpha_dash',
                Rule::unique('pages', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->page?->getKey()),
            ],
            'bodyHtml' => ['nullable', 'string'],
            'status' => ['required', Rule::in(array_column(PageStatus::cases(), 'value'))],
            'publishedAt' => ['nullable', 'date'],
        ], [], [
            'bodyHtml' => 'body',
            'publishedAt' => 'published at',
        ]);

        $publishedAt = $this->publishedAt !== '' ? $this->publishedAt : null;

        if ($this->status === PageStatus::Published->value && $publishedAt === null) {
            $publishedAt = now();
        }

        $page = $this->page instanceof Page
            ? tap($this->page)->update($this->payload($store, $publishedAt))
            : Page::withoutGlobalScopes()->create($this->payload($store, $publishedAt));

        $this->page = $page->refresh();
        $this->fillFromPage($this->page);
        $this->forgetNavigation($store, $navigation);

        session()->flash('status', 'Page saved');
        $this->dispatch('toast', type: 'success', message: __('Page saved'));
    }

    public function deletePage(NavigationService $navigation): void
    {
        abort_unless($this->page instanceof Page, 404);

        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorize('delete', $this->page);

        $this->page->delete();
        $this->forgetNavigation($store, $navigation);

        $this->redirectRoute('admin.pages.index', navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.admin.pages.form', [
            'isEditing' => $this->page instanceof Page,
        ])->layout('layouts.app', [
            'title' => $this->page ? __('Edit page') : __('Create page'),
        ]);
    }

    private function fillFromPage(Page $page): void
    {
        $this->title = $page->title;
        $this->handle = $page->handle;
        $this->bodyHtml = (string) $page->body_html;
        $this->status = $page->status->value;
        $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i') ?? '';
    }

    /**
     * @return array<string, mixed>
     */
    private function payload(Store $store, mixed $publishedAt): array
    {
        return [
            'store_id' => $store->getKey(),
            'title' => $this->title,
            'handle' => $this->handle,
            'body_html' => $this->bodyHtml,
            'status' => PageStatus::from($this->status),
            'published_at' => $publishedAt,
        ];
    }

    private function authorizeSave(): void
    {
        if ($this->page instanceof Page) {
            $this->authorize('update', $this->page);

            return;
        }

        $this->authorize('create', Page::class);
    }

    private function forgetNavigation(Store $store, NavigationService $navigation): void
    {
        NavigationMenu::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->get()
            ->each(fn (NavigationMenu $menu): mixed => $navigation->forget($menu));
    }
}