1	# API and Routing Specification
2
3	This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.
4
5	All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).
6
7	---
8
9	## 1. Web Routes (Blade/Livewire Pages)
10
11	### 1.1 Admin Auth Routes
12
13	No middleware beyond the default `web` group. These routes handle admin login and logout.
14
15	| Method | Path | Description | Component / Action |
16	|--------|------|-------------|--------------------|
17	| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
18	| POST | `/admin/login` | Process admin login | Handled by Livewire action |
19	| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
20	| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
21	| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
22	| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
23	| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |
24
25	Rate limit: `login` tier (5 per minute per IP).
26
27	---
28
29	### 1.2 Admin Routes
30
31	**Prefix:** `/admin`
32	**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`
33
34	All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).
35
36	> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.
37
38	#### Dashboard
39
40	| Method | Path | Description | Component |
41	|--------|------|-------------|-----------|
42	| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |
43
44	#### Products
45
46	| Method | Path | Description | Component |
47	|--------|------|-------------|-----------|
48	| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
49	| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
50	| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
51
52	#### Inventory
53
54	| Method | Path | Description | Component |
55	|--------|------|-------------|-----------|
56	| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |
57
58	#### Collections
59
60	| Method | Path | Description | Component |
61	|--------|------|-------------|-----------|
62	| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
63	| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
64	| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |
65
66	#### Orders
67
68	| Method | Path | Description | Component |
69	|--------|------|-------------|-----------|
70	| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
71	| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
72
73	#### Customers
74
75	| Method | Path | Description | Component |
76	|--------|------|-------------|-----------|
77	| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
78	| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
79
80	#### Discounts
81
82	| Method | Path | Description | Component |
83	|--------|------|-------------|-----------|
84	| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
85	| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
86	| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |
87
88	#### Settings
89
90	| Method | Path | Description | Component |
91	|--------|------|-------------|-----------|
92	| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
93	| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
94	| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |
95
96	#### Themes and Online Store
97
98	| Method | Path | Description | Component |
99	|--------|------|-------------|-----------|
100	| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
101	| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |
102
103	#### Pages
104
105	| Method | Path | Description | Component |
106	|--------|------|-------------|-----------|
107	| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
108	| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
109	| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |
110
111	#### Navigation
112
113	| Method | Path | Description | Component |
114	|--------|------|-------------|-----------|
115	| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |
116
117	#### Apps
118
119	| Method | Path | Description | Component |
120	|--------|------|-------------|-----------|
121	| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
122	| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |
123
124	#### Developers
125
126	| Method | Path | Description | Component |
127	|--------|------|-------------|-----------|
128	| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
129
130	> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
131
132	#### Analytics
133
134	| Method | Path | Description | Component |
135	|--------|------|-------------|-----------|
136	| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
137
138	#### Search Settings
139
140	| Method | Path | Description | Component |
141	|--------|------|-------------|-----------|
142	| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |
143
144	---
145
146	### 1.3 Storefront Routes
147
148	**Middleware:** `web`, `store.resolve`
149
150	These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.


Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and…


I have enough scope to begin. This is a huge system — I'll build it in pragmatic passes, focusing first on the critical e-commerce path, then broadening. Let me create the team infrastructure and progress tracker, then start.


Write

# Shop Implementation Progress

Last updated: 2026-04-16

## Strategy

Large scope (9 spec files, 660KB total). Approach:
1. Vertical slice first — critical path that lets a customer buy a product end-to-end.
2. Broaden into admin, customer accounts, search, analytics, apps.
3. Pest tests alongside each module; Playwright smoke tests at the end.
4. Team mode used for parallel-eligible work (models vs views, tests vs implementation).

## Phases

- [ ] Phase 1 — Foundation (organizations, stores, users, tenancy, auth)
- [ ] Phase 2 — Catalog (products, variants, inventory, collections, media)
- [ ] Phase 3 — Themes + storefront layout
- [ ] Phase 4 — Cart, checkout, discounts, shipping, taxes
- [ ] Phase 5 — Payments, orders, fulfillment
- [ ] Phase 6 — Customer accounts
- [ ] Phase 7 — Admin panel
- [ ] Phase 8 — Search (SQLite FTS)
- [ ] Phase 9 — Analytics
- [ ] Phase 10 — Apps & webhooks (stubs)
- [ ] Phase 11 — Polish
- [ ] Phase 12 — Full test suite + Playwright E2E


File created successfully at: /Users/fabianwesner/Herd/shop/specs/progress.md

# Shop Implementation Progress

Last updated: 2026-04-16

## Strategy

Large scope (9 spec files, 660KB total). Approach:
1. Vertical slice first — critical path that lets a customer buy a product end-to-end.
2. Broaden into admin, customer accounts, search, analytics, apps.
3. Pest tests alongside each module; Playwright smoke tests at the end.
4. Team mode used for parallel-eligible work (models vs views, tests vs implementation).

## Phases

- [ ] Phase 1 — Foundation (organizations, stores, users, tenancy, auth)
- [ ] Phase 2 — Catalog (products, variants, inventory, collections, media)
- [ ] Phase 3 — Themes + storefront layout
- [ ] Phase 4 — Cart, checkout, discounts, shipping, taxes
- [ ] Phase 5 — Payments, orders, fulfillment
- [ ] Phase 6 — Customer accounts
- [ ] Phase 7 — Admin panel
- [ ] Phase 8 — Search (SQLite FTS)
- [ ] Phase 9 — Analytics
- [ ] Phase 10 — Apps & webhooks (stubs)
- [ ] Phase 11 — Polish
- [ ] Phase 12 — Full test suite + Playwright E2E


TeamCreate

Build complete Laravel 12 multi-tenant shop per specs/ directory


Error: Team "shop-build" already exists at /Users/fabianwesner/.claude/teams/shop-build/config.json. Choose a different team_name, or run TeamDelete on the existing team first.


1	{
2	  "name": "shop-build",
3	  "description": "Building complete e-commerce shop system from specs",
4	  "createdAt": 1770906082929,
5	  "leadAgentId": "team-lead@shop-build",
6	  "leadSessionId": "679ce638-5913-4247-95ec-4008a56d0240",
7	  "members": [
8	    {
9	      "agentId": "team-lead@shop-build",
10	      "name": "team-lead",
11	      "agentType": "team-lead",
12	      "model": "claude-opus-4-6",
13	      "joinedAt": 1770906082929,
14	      "tmuxPaneId": "",
15	      "cwd": "/Users/fabianwesner/Herd/shop",
16	      "subscriptions": []
17	    },
18	    {
19	      "agentId": "models-and-factories@shop-build",
20	      "name": "models-and-factories",
21	      "agentType": "general-purpose",
22	      "model": "claude-opus-4-6",
23	      "prompt": "You are building ALL models and factories for an e-commerce shop at /Users/fabianwesner/Herd/shop (Laravel 12). All migrations already exist and pass. You must create:\n\n## CRITICAL RULES\n- Never use em-dash character\n- All monetary amounts stored as INTEGER in minor units (cents)\n- Every model needs: explicit return types, proper $fillable, casts() method, factory\n- Use `php artisan make:model ModelName -f --no-interaction` to create models with factories\n\n## Models to create:\n\n### 1. Update existing User model (app/Models/User.php)\nThe User model already exists. Update it:\n- The `password` column in the DB is actually `password_hash`. Override getAuthPassword() to return $this->password_hash\n- Add $fillable: name, email, password_hash, status, last_login_at\n- Add hidden: password_hash, two_factor_secret, two_factor_recovery_codes, remember_token\n- Add casts: email_verified_at => datetime, password_hash => hashed, last_login_at => datetime, status => string\n- Add relationship: belongsToMany(Store::class, 'store_users')->withPivot('role')->withTimestamps()  (use 'created_at' only)\n- Add method roleForStore(Store $store): ?StoreUserRole - looks up pivot and returns the role enum\n- Add initials() method (already exists, keep it)\n- Update the UserFactory: name (faker name), email (faker unique safe email), password_hash (Hash::make('password')), status ('active'), email_verified_at (now()), last_login_at (faker dateTimeBetween('-30 days'))\n\n### 2. Organization model\n- fillable: name, billing_email\n- hasMany(Store::class)\n- Factory: name (faker company), billing_email (faker companyEmail)\n\n### 3. Store model\n- fillable: organization_id, name, handle, status, default_currency, default_locale, timezone\n- casts: status => StoreStatus enum\n- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)\n- Factory: organization_id (Organization factory), name (faker company + ' Store'), handle (faker unique slug), status ('active'), default_currency ('EUR'), default_locale ('en'), timezone ('Europe/Berlin')\n\n### 4. StoreDomain model\n- fillable: store_id, hostname, type, is_primary, tls_mode\n- casts: type => StoreDomainType enum, is_primary => boolean\n- belongsTo(Store)\n- Factory: store_id (Store factory), hostname (faker unique domainName), type ('storefront'), is_primary (true), tls_mode ('managed')\n\n### 5. StoreUser (Pivot model)\n- Create as app/Models/StoreUser.php extending Illuminate\\Database\\Eloquent\\Relations\\Pivot\n- public $incrementing = false\n- protected $table = 'store_users'\n- casts: role => StoreUserRole enum\n- No factory needed\n\n### 6. StoreSettings model\n- protected $primaryKey = 'store_id'\n- public $incrementing = false\n- fillable: store_id, settings_json\n- casts: settings_json => array\n- belongsTo(Store)\n- IMPORTANT: Only has updated_at, no created_at. Set const CREATED_AT = null\n- Factory: store_id (Store factory), settings_json ([])\n\n### 7. Product model (uses BelongsToStore trait)\n- fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at\n- casts: status => ProductStatus, tags => array, published_at => datetime\n- hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection, 'collection_products')\n- Factory: store_id (Store factory), title (faker words 3 joined), handle (Str::slug of title), status ('active'), description_html (faker paragraphs 2 as html), vendor (faker company), product_type (random of Shirts/Pants/Shoes/Accessories), tags (json_encode [random items]), published_at (now())\n- States: draft (status=draft, published_at=null), archived (status=archived)\n\n### 8. ProductOption model\n- fillable: product_id, name, position\n- belongsTo(Product), hasMany(ProductOptionValue)\n- No timestamps\n- public $timestamps = false\n- Factory: product_id (Product factory), name (faker randomElement ['Size', 'Color', 'Material']), position (0)\n\n### 9. ProductOptionValue model\n- fillable: product_option_id, value, position\n- belongsTo(ProductOption)\n- public $timestamps = false\n- Factory: product_option_id (ProductOption factory), value (faker word), position (0)\n\n### 10. ProductVariant model\n- fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status\n- casts: requires_shipping => boolean, is_default => boolean, status => VariantStatus\n- belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue, 'variant_option_values', 'variant_id', 'product_option_value_id')\n- Factory: product_id (Product factory), sku (faker bothify 'SKU-####')), price_amount (faker numberBetween(499, 9999)), currency ('EUR'), requires_shipping (true), is_default (false), position (0), status ('active')\n\n### 11. InventoryItem model (uses BelongsToStore trait)\n- fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy\n- casts: policy => InventoryPolicy\n- No timestamps\n- public $timestamps = false\n- belongsTo(ProductVariant, 'variant_id')\n- Factory: store_id (Store factory), variant_id (ProductVariant factory), quantity_on_hand (faker numberBetween(0, 100)), quantity_reserved (0), policy ('deny')\n\n### 12. Collection model (uses BelongsToStore trait)\n- fillable: store_id, title, handle, description_html, type, status\n- casts: status => CollectionStatus\n- belongsToMany(Product, 'collection_products')\n- Factory: store_id (Store factory), title (faker words 2 joined), handle (Str::slug of title), type ('manual'), status ('active')\n\n### 13. ProductMedia model\n- fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status\n- casts: type => MediaType, status => MediaStatus\n- No standard timestamps (only created_at)\n- const UPDATED_AT = null\n- belongsTo(Product)\n- Factory: product_id (Product factory), type ('image'), storage_key (faker filePath), alt_text (faker sentence), width (800), height (600), mime_type ('image/jpeg'), byte_size (faker numberBetween(10000, 500000)), position (0), status ('ready')\n\n### 14. Theme model (uses BelongsToStore trait)\n- fillable: store_id, name, version, status, published_at\n- casts: status => ThemeStatus, published_at => datetime\n- hasMany(ThemeFile), hasOne(ThemeSettings)\n- Factory: store_id (Store factory), name ('Default Theme'), version ('1.0.0'), status ('published'), published_at (now())\n\n### 15. ThemeFile model\n- fillable: theme_id, path, storage_key, sha256, byte_size\n- No timestamps\n- public $timestamps = false\n- belongsTo(Theme)\n\n### 16. ThemeSettings model\n- primaryKey = 'theme_id', incrementing = false\n- fillable: theme_id, settings_json\n- casts: settings_json => array\n- const CREATED_AT = null\n- belongsTo(Theme)\n\n### 17. Page model (uses BelongsToStore trait)\n- fillable: store_id, title, handle, body_html, status, published_at\n- casts: status => PageStatus, published_at => datetime\n- Factory: store_id (Store factory), title (faker words 2 joined), handle (Str::slug of title), status ('published'), body_html (faker paragraphs 3 html), published_at (now())\n\n### 18. NavigationMenu model (uses BelongsToStore trait)\n- fillable: store_id, handle, title\n- hasMany(NavigationItem, 'menu_id')\n- Factory: store_id (Store factory), handle (faker unique slug), title (faker words 2 joined)\n\n### 19. NavigationItem model\n- fillable: menu_id, type, label, url, resource_id, position\n- casts: type => NavigationItemType\n- No timestamps\n- public $timestamps = false\n- belongsTo(NavigationMenu, 'menu_id')\n- Factory: menu_id (NavigationMenu factory), type ('link'), label (faker words 2 joined), url (faker url), position (0)\n\n### 20. Customer model (uses BelongsToStore trait)\n- Implements Authenticatable interface (extends Illuminate\\Foundation\\Auth\\User)\n- fillable: store_id, email, password_hash, name, marketing_opt_in\n- hidden: password_hash\n- casts: marketing_opt_in => boolean, password_hash => hashed\n- getAuthPassword() returns $this->password_hash\n- hasMany(CustomerAddress), hasMany(Order), hasMany(Cart)\n- Factory: store_id (Store factory), email (faker unique safeEmail), password_hash (Hash::make('password')), name (faker name), marketing_opt_in (false)\n\n### 21. CustomerAddress model\n- fillable: customer_id, label, address_json, is_default\n- casts: address_json => array, is_default => boolean\n- No timestamps\n- public $timestamps = false\n- belongsTo(Customer)\n- Factory: customer_id (Customer factory), label ('Home'), address_json (realistic address array), is_default (true)\n\n### 22. Cart model (uses BelongsToStore trait)\n- fillable: store_id, customer_id, currency, cart_version, status\n- casts: status => CartStatus\n- belongsTo(Customer), hasMany(CartLine), hasMany(Checkout)\n- Factory: store_id (Store factory), currency ('EUR'), cart_version (1), status ('active')\n\n### 23. CartLine model\n- fillable: cart_id, variant_id, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount, line_total_amount\n- No timestamps\n- public $timestamps = false\n- belongsTo(Cart), belongsTo(ProductVariant, 'variant_id')\n- Factory: cart_id (Cart factory), variant_id (ProductVariant factory), quantity (1), unit_price_amount (2499), line_subtotal_amount (2499), line_total_amount (2499)\n\n### 24. Checkout model (uses BelongsToStore trait)\n- fillable: store_id, cart_id, customer_id, status, payment_method, email, shipping_address_json, billing_address_json, shipping_method_id, discount_code, tax_provider_snapshot_json, totals_json, expires_at\n- casts: status => CheckoutStatus, payment_method => PaymentMethod, shipping_address_json => array, billing_address_json => array, totals_json => array, expires_at => datetime\n- belongsTo(Cart), belongsTo(Customer)\n\n### 25. ShippingZone model (uses BelongsToStore trait)\n- fillable: store_id, name, countries_json, regions_json\n- casts: countries_json => array, regions_json => array\n- No timestamps, $timestamps = false\n- hasMany(ShippingRate, 'zone_id')\n\n### 26. ShippingRate model\n- fillable: zone_id, name, type, config_json, is_active\n- casts: type => ShippingRateType, config_json => array, is_active => boolean\n- No timestamps, $timestamps = false\n- belongsTo(ShippingZone, 'zone_id')\n\n### 27. TaxSettings model\n- primaryKey = 'store_id', incrementing = false\n- fillable: store_id, mode, provider, prices_include_tax, config_json\n- casts: mode => TaxMode, prices_include_tax => boolean, config_json => array\n- No timestamps, $timestamps = false\n- belongsTo(Store)\n\n### 28. Discount model (uses BelongsToStore trait)\n- fillable: store_id, type, code, value_type, value_amount, starts_at, ends_at, usage_limit, usage_count, rules_json, status\n- casts: type => DiscountType, value_type => DiscountValueType, status => DiscountStatus, starts_at => datetime, ends_at => datetime, rules_json => array\n\n### 29. Order model (uses BelongsToStore trait)\n- fillable: store_id, customer_id, order_number, payment_method, status, financial_status, fulfillment_status, currency, subtotal_amount, discount_amount, shipping_amount, tax_amount, total_amount, email, billing_address_json, shipping_address_json, placed_at\n- casts: payment_method => PaymentMethod, status => OrderStatus, financial_status => FinancialStatus, fulfillment_status => FulfillmentStatus, billing_address_json => array, shipping_address_json => array, placed_at => datetime\n- belongsTo(Customer), hasMany(OrderLine), hasMany(Payment), hasMany(Refund), hasMany(Fulfillment)\n\n### 30. OrderLine model\n- fillable: order_id, product_id, variant_id, title_snapshot, sku_snapshot, quantity, unit_price_amount, total_amount, tax_lines_json, discount_allocations_json\n- casts: tax_lines_json => array, discount_allocations_json => array\n- No timestamps, $timestamps = false\n- belongsTo(Order), belongsTo(Product), belongsTo(ProductVariant, 'variant_id')\n\n### 31. Payment model\n- fillable: order_id, provider, method, provider_payment_id, status, amount, currency, raw_json_encrypted\n- casts: method => PaymentMethod, status => PaymentStatus, raw_json_encrypted => encrypted\n- const UPDATED_AT = null\n- belongsTo(Order)\n\n### 32. Refund model\n- fillable: order_id, payment_id, amount, reason, status, provider_refund_id\n- casts: status => RefundStatus\n- const UPDATED_AT = null\n- belongsTo(Order), belongsTo(Payment)\n\n### 33. Fulfillment model\n- fillable: order_id, status, tracking_company, tracking_number, tracking_url, shipped_at\n- casts: status => FulfillmentShipmentStatus, shipped_at => datetime\n- const UPDATED_AT = null\n- belongsTo(Order), hasMany(FulfillmentLine)\n\n### 34. FulfillmentLine model\n- fillable: fulfillment_id, order_line_id, quantity\n- No timestamps, $timestamps = false\n- belongsTo(Fulfillment), belongsTo(OrderLine)\n\n### 35. AnalyticsEvent model (uses BelongsToStore trait)\n- table: analytics_events\n- fillable: store_id, type, session_id, customer_id, properties_json, client_event_id, occurred_at\n- casts: properties_json => array, occurred_at => datetime\n- const UPDATED_AT = null\n\n### 36. AnalyticsDaily model (uses BelongsToStore trait)\n- table: analytics_daily\n- Composite primary key - no autoincrement id\n- $incrementing = false\n- $timestamps = false\n- fillable: store_id, date, orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count, checkout_completed_count\n\n### 37-40. App, AppInstallation, WebhookSubscription, WebhookDelivery\nCreate simple models for the apps/webhooks tables with appropriate relationships and fillable.\n\n## BelongsToStore trait\nCreate `app/Models/Concerns/BelongsToStore.php`:\n```php\ntrait BelongsToStore {\n    public static function bootBelongsToStore(): void {\n        static::addGlobalScope(new StoreScope);\n        static::creating(function ($model) {\n            if (!$model->store_id && app()->bound('current_store')) {\n                $model->store_id = app('current_store')->id;\n            }\n        });\n    }\n    public function store(): BelongsTo { return $this->belongsTo(Store::class); }\n}\n```\n\n## StoreScope\nCreate `app/Models/Scopes/StoreScope.php`:\n```php\nclass StoreScope implements Scope {\n    public function apply(Builder $builder, Model $model): void {\n        if (app()->bound('current_store')) {\n            $builder->where($model->getTable().'.store_id', app('current_store')->id);\n        }\n    }\n}\n```\n\nAfter creating all models and factories, run:\n1. `vendor/bin/pint --dirty` to fix code style\n2. `php artisan migrate:fresh` to verify everything still works\n\nDO NOT write tests - just create the models and factories.",
24	      "color": "green",
25	      "planModeRequired": false,
26	      "joinedAt": 1770906481855,
27	      "tmuxPaneId": "in-process",
28	      "cwd": "/Users/fabianwesner/Herd/shop",
29	      "subscriptions": [],
30	      "backendType": "in-process"
31	    },
32	    {
33	      "agentId": "middleware-auth-services@shop-build",
34	      "name": "middleware-auth-services",
35	      "agentType": "general-purpose",
36	      "model": "claude-opus-4-6",
37	      "prompt": "You are building the middleware, authentication, authorization, and core services for an e-commerce shop at /Users/fabianwesner/Herd/shop (Laravel 12 + Livewire v4). All migrations exist and pass. Models are being created by another agent.\n\n## CRITICAL RULES\n- Never use the em-dash character\n- Read existing files before modifying them\n- Use proper PHP 8.4 features\n\n## 1. ResolveStore Middleware\n\nCreate `app/Http/Middleware/ResolveStore.php`:\n\n```php\nclass ResolveStore\n{\n    public function handle(Request $request, Closure $next, string $type = 'storefront'): Response\n    {\n        if ($type === 'storefront') {\n            return $this->resolveFromHostname($request, $next);\n        }\n        return $this->resolveFromSession($request, $next);\n    }\n    \n    private function resolveFromHostname($request, $next)\n    {\n        $hostname = $request->getHost();\n        $storeId = Cache::remember(\"store_domain:{$hostname}\", 300, function () use ($hostname) {\n            return StoreDomain::where('hostname', $hostname)->value('store_id');\n        });\n        \n        if (!$storeId) { abort(404); }\n        \n        $store = Store::find($storeId);\n        if (!$store) { abort(404); }\n        if ($store->status === StoreStatus::Suspended) { abort(503); }\n        \n        app()->instance('current_store', $store);\n        View::share('currentStore', $store);\n        \n        return $next($request);\n    }\n    \n    private function resolveFromSession($request, $next) \n    {\n        $storeId = session('current_store_id');\n        if (!$storeId) { abort(403, 'No store selected'); }\n        \n        $user = $request->user();\n        if (!$user) { return redirect('/admin/login'); }\n        \n        $hasAccess = DB::table('store_users')\n            ->where('store_id', $storeId)\n            ->where('user_id', $user->id)\n            ->exists();\n        if (!$hasAccess) { abort(403); }\n        \n        $store = Store::find($storeId);\n        if (!$store) { abort(403); }\n        \n        app()->instance('current_store', $store);\n        View::share('currentStore', $store);\n        \n        return $next($request);\n    }\n}\n```\n\n## 2. Update bootstrap/app.php\n\nRead the EXISTING bootstrap/app.php first, then update it:\n- Register middleware aliases: 'store.resolve' => ResolveStore::class\n- Set redirect targets: guests => '/admin/login', users => '/admin'\n- Add API routes file registration\n\n## 3. Admin Auth Login Livewire Component\n\nCreate `app/Livewire/Admin/Auth/Login.php`:\n- Properties: email, password, remember (bool)\n- Method login(): validates, rate-limits (5/min per IP), attempts auth with web guard\n- On success: regenerate session, look up user's first store via store_users, set session('current_store_id'), redirect /admin\n- On failure: add error 'Invalid credentials'\n- Uses a simple Blade view at resources/views/livewire/admin/auth/login.blade.php\n- The layout should be a minimal auth layout (not the full admin layout)\n\nCreate the Blade view with Flux UI components:\n- flux:input for email, flux:input type=password, flux:checkbox for remember, flux:button for submit\n- Show validation errors\n- Link to forgot password\n\n## 4. Admin Auth Layout\n\nCreate `resources/views/components/layouts/admin-auth.blade.php`:\n- Minimal centered layout with card for auth forms\n- Include @livewireStyles, @livewireScripts, @vite\n\n## 5. Customer Auth Provider\n\nCreate `app/Auth/CustomerUserProvider.php`:\n- Implements Illuminate\\Contracts\\Auth\\UserProvider\n- Constructor takes model class name\n- retrieveById, retrieveByToken, updateRememberToken, retrieveByCredentials, validateCredentials\n- retrieveByCredentials: must inject store_id from current_store into the query\n- Register in AppServiceProvider: Auth::provider('customer', fn($app, $config) => new CustomerUserProvider($config['model']))\n\n## 6. AppServiceProvider Updates\n\nUpdate `app/Providers/AppServiceProvider.php`:\n- Register the customer auth provider\n- Register rate limiters for login (5 per minute per IP)\n- Bind PaymentProvider interface to MockPaymentProvider (stub for now)\n\n## 7. Routes (routes/web.php)\n\nReplace the entire routes/web.php with proper route definitions:\n\n```php\n// Admin auth routes (no store.resolve needed)\nRoute::prefix('admin')->group(function () {\n    Route::get('login', \\App\\Livewire\\Admin\\Auth\\Login::class)->name('admin.login')->middleware('guest');\n    Route::post('logout', function () {\n        Auth::guard('web')->logout();\n        request()->session()->invalidate();\n        request()->session()->regenerateToken();\n        return redirect('/admin/login');\n    })->name('admin.logout')->middleware('auth');\n});\n\n// Admin routes (auth + store resolution)\nRoute::prefix('admin')->middleware(['auth', 'verified', 'store.resolve:admin'])->group(function () {\n    Route::get('/', AdminDashboard::class)->name('admin.dashboard');\n    // ... all admin routes\n});\n\n// Storefront routes\nRoute::middleware(['store.resolve:storefront'])->group(function () {\n    Route::get('/', StorefrontHome::class)->name('storefront.home');\n    // ... all storefront routes\n});\n```\n\nNOTE: Many Livewire components don't exist yet - that's OK. Use class string references and they'll be created later. What's important is that the routes are defined correctly.\n\n## 8. Authorization Policies\n\nCreate policies for each resource. Each checks user role via store_users:\n\nPermission matrix:\n- Owner: everything\n- Admin: everything except delete store\n- Staff: manage products, orders (CRUD), discounts, fulfillments, view analytics, view/update customers\n- Support: read-only orders, view customers\n\nCreate a base helper trait `app/Policies/Concerns/ChecksStoreRole.php`:\n```php\ntrait ChecksStoreRole {\n    protected function getUserRole(User $user): ?StoreUserRole {\n        if (!app()->bound('current_store')) return null;\n        return $user->roleForStore(app('current_store'));\n    }\n    protected function hasMinRole(User $user, StoreUserRole ...$roles): bool {\n        $role = $this->getUserRole($user);\n        return $role && in_array($role, $roles);\n    }\n}\n```\n\nCreate policies: ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy.\n\nAfter creating everything, run `vendor/bin/pint --dirty` to fix code style.\n\nDO NOT create test files - just the production code.",
38	      "color": "yellow",
39	      "planModeRequired": false,
40	      "joinedAt": 1770906514465,
41	      "tmuxPaneId": "in-process",
42	      "cwd": "/Users/fabianwesner/Herd/shop",
43	      "subscriptions": [],
44	      "backendType": "in-process"
45	    }
46	  ]
47	}

shop-build

models-and-factories

Prompt:
You are building ALL models and factories for an e-commerce shop at /Users/fabianwesner/Herd/shop (Laravel 12). All migrations already exist and pass. You must create:

## CRITICAL RULES
- Never use em-dash character
- All monetary amounts stored as INTEGER in minor units (cents)
- Every model needs: explicit return types, proper $fillable, casts() method, factory
- Use `php artisan make:model ModelName -f --no-interaction` to create models with factories

## Models to create:

### 1. Update existing User model (app/Models/User.php)
The User model already exists. Update it:
- The `password` column in the DB is actually `password_hash`. Override getAuthPassword() to return $this->password_hash
- Add $fillable: name, email, password_hash, status, last_login_at
- Add hidden: password_hash, two_factor_secret, two_factor_recovery_codes, remember_token
- Add casts: email_verified_at => datetime, password_hash => hashed, last_login_at => datetime, status => string
- Add relationship: belongsToMany(Store::class, 'store_users')->withPivot('role')->withTimestamps()  (use 'created_at' only)
- Add method roleForStore(Store $store): ?StoreUserRole - looks up pivot and returns the role enum
- Add initials() method (already exists, keep it)
- Update the UserFactory: name (faker name), email (faker unique safe email), password_hash (Hash::make('password')), status ('active'), email_verified_at (now()), last_login_at (faker dateTimeBetween('-30 days'))

### 2. Organization model
- fillable: name, billing_email
- hasMany(Store::class)
- Factory: name (faker company), billing_email (faker companyEmail)

### 3. Store model
- fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts: status => StoreStatus enum
- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)
- Factory: organization_id (Organization factory), name (faker company + ' Store'), handle (faker unique slug), status ('active'), default_currency ('EUR'), default_locale ('en'), timezone ('Europe/Berlin')

### 4. StoreDomain model
- fillable: store_id, hostname, type, is_primary, tls_mode
- casts: type => StoreDomainType enum, is_primary => boolean
- belongsTo(Store)
- Factory: store_id (Store factory), hostname (faker unique domainName), type ('storefront'), is_primary (true), tls_mode ('managed')

### 5. StoreUser (Pivot model)
- Create as app/Models/StoreUser.php extending Illuminate\Database\Eloquent\Relations\Pivot
- public $incrementing = false
- protected $table = 'store_users'
- casts: role => StoreUserRole enum
- No factory needed

### 6. StoreSettings model
- protected $primaryKey = 'store_id'
- public $incrementing = false
- fillable: store_id, settings_json
- casts: settings_json => array
- belongsTo(Store)
- IMPORTANT: Only has updated_at, no created_at. Set const CREATED_AT = null
- Factory: store_id (Store factory), settings_json ([])

### 7. Product model (uses BelongsToStore trait)
- fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at
- casts: status => ProductStatus, tags => array, published_at => datetime
- hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection, 'collection_products')
- Factory: store_id (Store factory), title (faker words 3 joined), handle (Str::slug of title), status ('active'), description_html (faker paragraphs 2 as html), vendor (faker company), product_type (random of Shirts/Pants/Shoes/Accessories), tags (json_encode [random items]), published_at (now())
- States: draft (status=draft, published_at=null), archived (status=archived)

### 8. ProductOption model
- fillable: product_id, name, position
- belongsTo(Product), hasMany(ProductOptionValue)
- No timestamps
- public $timestamps = false
- Factory: product_id (Product factory), name (faker randomElement ['Size', 'Color', 'Material']), position (0)

### 9. ProductOptionValue model
- fillable: product_option_id, value, position
- belongsTo(ProductOption)
- public $timestamps = false
- Factory: product_option_id (ProductOption factory), value (faker word), position (0)

### 10. ProductVariant model
- fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status
- casts: requires_shipping => boolean, is_default => boolean, status => VariantStatus
- belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue, 'variant_option_values', 'variant_id', 'product_option_value_id')
- Factory: product_id (Product factory), sku (faker bothify 'SKU-####')), price_amount (faker numberBetween(499, 9999)), currency ('EUR'), requires_shipping (true), is_default (false), position (0), status ('active')

### 11. InventoryItem model (uses BelongsToStore trait)
- fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy
- casts: policy => InventoryPolicy
- No timestamps
- public $timestamps = false
- belongsTo(ProductVariant, 'variant_id')
- Factory: store_id (Store factory), variant_id (ProductVariant factory), quantity_on_hand (faker numberBetween(0, 100)), quantity_reserved (0), policy ('deny')

### 12. Collection model (uses BelongsToStore trait)
- fillable: store_id, title, handle, description_html, type, status
- casts: status => CollectionStatus
- belongsToMany(Product, 'collection_products')
- Factory: store_id (Store factory), title (faker words 2 joined), handle (Str::slug of title), type ('manual'), status ('active')

### 13. ProductMedia model
- fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status
- casts: type => MediaType, status => MediaStatus
- No standard timestamps (only created_at)
- const UPDATED_AT = null
- belongsTo(Product)
- Factory: product_id (Product factory), type ('image'), storage_key (faker filePath), alt_text (faker sentence), width (800), height (600), mime_type ('image/jpeg'), byte_size (faker numberBetween(10000, 500000)), position (0), status ('ready')

### 14. Theme model (uses BelongsToStore trait)
- fillable: store_id, name, version, status, published_at
- casts: status => ThemeStatus, published_at => datetime
- hasMany(ThemeFile), hasOne(ThemeSettings)
- Factory: store_id (Store factory), name ('Default Theme'), version ('1.0.0'), status ('published'), published_at (now())

### 15. ThemeFile model
- fillable: theme_id, path, storage_key, sha256, byte_size
- No timestamps
- public $timestamps = false
- belongsTo(Theme)

### 16. ThemeSettings model
- primaryKey = 'theme_id', incrementing = false
- fillable: theme_id, settings_json
- casts: settings_json => array
- const CREATED_AT = null
- belongsTo(Theme)

### 17. Page model (uses BelongsToStore trait)
- fillable: store_id, title, handle, body_html, status, published_at
- casts: status => PageStatus, published_at => datetime
- Factory: store_id (Store factory), title (faker words 2 joined), handle (Str::slug of title), status ('published'), body_html (faker paragraphs 3 html), published_at (now())

### 18. NavigationMenu model (uses BelongsToStore trait)
- fillable: store_id, handle, title
- hasMany(NavigationItem, 'menu_id')
- Factory: store_id (Store factory), handle (faker unique slug), title (faker words 2 joined)

### 19. NavigationItem model
- fillable: menu_id, type, label, url, resource_id, position
- casts: type => NavigationItemType
- No timestamps
- public $timestamps = false
- belongsTo(NavigationMenu, 'menu_id')
- Factory: menu_id (NavigationMenu factory), type ('link'), label (faker words 2 joined), url (faker url), position (0)

### 20. Customer model (uses BelongsToStore trait)
- Implements Authenticatable interface (extends Illuminate\Foundation\Auth\User)
- fillable: store_id, email, password_hash, name, marketing_opt_in
- hidden: password_hash
- casts: marketing_opt_in => boolean, password_hash => hashed
- getAuthPassword() returns $this->password_hash
- hasMany(CustomerAddress), hasMany(Order), hasMany(Cart)
- Factory: store_id (Store factory), email (faker unique safeEmail), password_hash (Hash::make('password')), name (faker name), marketing_opt_in (false)

### 21. CustomerAddress model
- fillable: customer_id, label, address_json, is_default
- casts: address_json => array, is_default => boolean
- No timestamps
- public $timestamps = false
- belongsTo(Customer)
- Factory: customer_id (Customer factory), label ('Home'), address_json (realistic address array), is_default (true)

### 22. Cart model (uses BelongsToStore trait)
- fillable: store_id, customer_id, currency, cart_version, status
- casts: status => CartStatus
- belongsTo(Customer), hasMany(CartLine), hasMany(Checkout)
- Factory: store_id (Store factory), currency ('EUR'), cart_version (1), status ('active')

### 23. CartLine model
- fillable: cart_id, variant_id, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount, line_total_amount
- No timestamps
- public $timestamps = false
- belongsTo(Cart), belongsTo(ProductVariant, 'variant_id')
- Factory: cart_id (Cart factory), variant_id (ProductVariant factory), quantity (1), unit_price_amount (2499), line_subtotal_amount (2499), line_total_amount (2499)

### 24. Checkout model (uses BelongsToStore trait)
- fillable: store_id, cart_id, customer_id, status, payment_method, email, shipping_address_json, billing_address_json, shipping_method_id, discount_code, tax_provider_snapshot_json, totals_json, expires_at
- casts: status => CheckoutStatus, payment_method => PaymentMethod, shipping_address_json => array, billing_address_json => array, totals_json => array, expires_at => datetime
- belongsTo(Cart), belongsTo(Customer)

### 25. ShippingZone model (uses BelongsToStore trait)
- fillable: store_id, name, countries_json, regions_json
- casts: countries_json => array, regions_json => array
- No timestamps, $timestamps = false
- hasMany(ShippingRate, 'zone_id')

### 26. ShippingRate model
- fillable: zone_id, name, type, config_json, is_active
- casts: type => ShippingRateType, config_json => array, is_active => boolean
- No timestamps, $timestamps = false
- belongsTo(ShippingZone, 'zone_id')

### 27. TaxSettings model
- primaryKey = 'store_id', incrementing = false
- fillable: store_id, mode, provider, prices_include_tax, config_json
- casts: mode => TaxMode, prices_include_tax => boolean, config_json => array
- No timestamps, $timestamps = false
- belongsTo(Store)

### 28. Discount model (uses BelongsToStore trait)
- fillable: store_id, type, code, value_type, value_amount, starts_at, ends_at, usage_limit, usage_count, rules_json, status
- casts: type => DiscountType, value_type => DiscountValueType, status => DiscountStatus, starts_at => datetime, ends_at => datetime, rules_json => array

### 29. Order model (uses BelongsToStore trait)
- fillable: store_id, customer_id, order_number, payment_method, status, financial_status, fulfillment_status, currency, subtotal_amount, discount_amount, shipping_amount, tax_amount, total_amount, email, billing_address_json, shipping_address_json, placed_at
- casts: payment_method => PaymentMethod, status => OrderStatus, financial_status => FinancialStatus, fulfillment_status => FulfillmentStatus, billing_address_json => array, shipping_address_json => array, placed_at => datetime
- belongsTo(Customer), hasMany(OrderLine), hasMany(Payment), hasMany(Refund), hasMany(Fulfillment)

### 30. OrderLine model
- fillable: order_id, product_id, variant_id, title_snapshot, sku_snapshot, quantity, unit_price_amount, total_amount, tax_lines_json, discount_allocations_json
- casts: tax_lines_json => array, discount_allocations_json => array
- No timestamps, $timestamps = false
- belongsTo(Order), belongsTo(Product), belongsTo(ProductVariant, 'variant_id')

### 31. Payment model
- fillable: order_id, provider, method, provider_payment_id, status, amount, currency, raw_json_encrypted
- casts: method => PaymentMethod, status => PaymentStatus, raw_json_encrypted => encrypted
- const UPDATED_AT = null
- belongsTo(Order)

### 32. Refund model
- fillable: order_id, payment_id, amount, reason, status, provider_refund_id
- casts: status => RefundStatus
- const UPDATED_AT = null
- belongsTo(Order), belongsTo(Payment)

### 33. Fulfillment model
- fillable: order_id, status, tracking_company, tracking_number, tracking_url, shipped_at
- casts: status => FulfillmentShipmentStatus, shipped_at => datetime
- const UPDATED_AT = null
- belongsTo(Order), hasMany(FulfillmentLine)

### 34. FulfillmentLine model
- fillable: fulfillment_id, order_line_id, quantity
- No timestamps, $timestamps = false
- belongsTo(Fulfillment), belongsTo(OrderLine)

### 35. AnalyticsEvent model (uses BelongsToStore trait)
- table: analytics_events
- fillable: store_id, type, session_id, customer_id, properties_json, client_event_id, occurred_at
- casts: properties_json => array, occurred_at => datetime
- const UPDATED_AT = null

### 36. AnalyticsDaily model (uses BelongsToStore trait)
- table: analytics_daily
- Composite primary key - no autoincrement id
- $incrementing = false
- $timestamps = false
- fillable: store_id, date, orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count, checkout_completed_count

### 37-40. App, AppInstallation, WebhookSubscription, WebhookDelivery
Create simple models for the apps/webhooks tables with appropriate relationships and fillable.

## BelongsToStore trait
Create `app/Models/Concerns/BelongsToStore.php`:
```php
trait BelongsToStore {
    public static function bootBelongsToStore(): void {
        static::addGlobalScope(new StoreScope);
        static::creating(function ($model) {
            if (!$model->store_id && app()->bound('current_store')) {
                $model->store_id = app('current_store')->id;
            }
        });
    }
    public function store(): BelongsTo { return $this->belongsTo(Store::class); }
}
```

## StoreScope
Create `app/Models/Scopes/StoreScope.php`:
```php
class StoreScope implements Scope {
    public function apply(Builder $builder, Model $model): void {
        if (app()->bound('current_store')) {
            $builder->where($model->getTable().'.store_id', app('current_store')->id);
        }
    }
}
```

After creating all models and factories, run:
1. `vendor/bin/pint --dirty` to fix code style
2. `php artisan migrate:fresh` to verify everything still works

DO NOT write tests - just create the models and factories.

middleware-auth-services

Prompt:
You are building the middleware, authentication, authorization, and core services for an e-commerce shop at /Users/fabianwesner/Herd/shop (Laravel 12 + Livewire v4). All migrations exist and pass. Models are being created by another agent.

## CRITICAL RULES
- Never use the em-dash character
- Read existing files before modifying them
- Use proper PHP 8.4 features

## 1. ResolveStore Middleware

Create `app/Http/Middleware/ResolveStore.php`:

```php
class ResolveStore
{
    public function handle(Request $request, Closure $next, string $type = 'storefront'): Response
    {
        if ($type === 'storefront') {
            return $this->resolveFromHostname($request, $next);
        }
        return $this->resolveFromSession($request, $next);
    }

    private function resolveFromHostname($request, $next)
    {
        $hostname = $request->getHost();
        $storeId = Cache::remember("store_domain:{$hostname}", 300, function () use ($hostname) {
            return StoreDomain::where('hostname', $hostname)->value('store_id');
        });

        if (!$storeId) { abort(404); }

        $store = Store::find($storeId);
        if (!$store) { abort(404); }
        if ($store->status === StoreStatus::Suspended) { abort(503); }

        app()->instance('current_store', $store);
        View::share('currentStore', $store);

        return $next($request);
    }

    private function resolveFromSession($request, $next)
    {
        $storeId = session('current_store_id');
        if (!$storeId) { abort(403, 'No store selected'); }

        $user = $request->user();
        if (!$user) { return redirect('/admin/login'); }

        $hasAccess = DB::table('store_users')
            ->where('store_id', $storeId)
            ->where('user_id', $user->id)
            ->exists();
        if (!$hasAccess) { abort(403); }

        $store = Store::find($storeId);
        if (!$store) { abort(403); }

        app()->instance('current_store', $store);
        View::share('currentStore', $store);

        return $next($request);
    }
}
```

## 2. Update bootstrap/app.php

Read the EXISTING bootstrap/app.php first, then update it:
- Register middleware aliases: 'store.resolve' => ResolveStore::class
- Set redirect targets: guests => '/admin/login', users => '/admin'
- Add API routes file registration

## 3. Admin Auth Login Livewire Component

Create `app/Livewire/Admin/Auth/Login.php`:
- Properties: email, password, remember (bool)
- Method login(): validates, rate-limits (5/min per IP), attempts auth with web guard
- On success: regenerate session, look up user's first store via store_users, set session('current_store_id'), redirect /admin
- On failure: add error 'Invalid credentials'
- Uses a simple Blade view at resources/views/livewire/admin/auth/login.blade.php
- The layout should be a minimal auth layout (not the full admin layout)

Create the Blade view with Flux UI components:
- flux:input for email, flux:input type=password, flux:checkbox for remember, flux:button for submit
- Show validation errors
- Link to forgot password

## 4. Admin Auth Layout

Create `resources/views/components/layouts/admin-auth.blade.php`:
- Minimal centered layout with card for auth forms
- Include @livewireStyles, @livewireScripts, @vite

## 5. Customer Auth Provider

Create `app/Auth/CustomerUserProvider.php`:
- Implements Illuminate\Contracts\Auth\UserProvider
- Constructor takes model class name
- retrieveById, retrieveByToken, updateRememberToken, retrieveByCredentials, validateCredentials
- retrieveByCredentials: must inject store_id from current_store into the query
- Register in AppServiceProvider: Auth::provider('customer', fn($app, $config) => new CustomerUserProvider($config['model']))

## 6. AppServiceProvider Updates

Update `app/Providers/AppServiceProvider.php`:
- Register the customer auth provider
- Register rate limiters for login (5 per minute per IP)
- Bind PaymentProvider interface to MockPaymentProvider (stub for now)

## 7. Routes (routes/web.php)

Replace the entire routes/web.php with proper route definitions:

```php
// Admin auth routes (no store.resolve needed)
Route::prefix('admin')->group(function () {
    Route::get('login', \App\Livewire\Admin\Auth\Login::class)->name('admin.login')->middleware('guest');
    Route::post('logout', function () {
        Auth::guard('web')->logout();
        request()->session()->invalidate();
        request()->session()->regenerateToken();
        return redirect('/admin/login');
    })->name('admin.logout')->middleware('auth');
});

// Admin routes (auth + store resolution)
Route::prefix('admin')->middleware(['auth', 'verified', 'store.resolve:admin'])->group(function () {
    Route::get('/', AdminDashboard::class)->name('admin.dashboard');
    // ... all admin routes
});

// Storefront routes
Route::middleware(['store.resolve:storefront'])->group(function () {
    Route::get('/', StorefrontHome::class)->name('storefront.home');
    // ... all storefront routes
});
```

NOTE: Many Livewire components don't exist yet - that's OK. Use class string references and they'll be created later. What's important is that the routes are defined correctly.

## 8. Authorization Policies

Create policies for each resource. Each checks user role via store_users:

Permission matrix:
- Owner: everything
- Admin: everything except delete store
- Staff: manage products, orders (CRUD), discounts, fulfillments, view analytics, view/update customers
- Support: read-only orders, view customers

Create a base helper trait `app/Policies/Concerns/ChecksStoreRole.php`:
```php
trait ChecksStoreRole {
    protected function getUserRole(User $user): ?StoreUserRole {
        if (!app()->bound('current_store')) return null;
        return $user->roleForStore(app('current_store'));
    }
    protected function hasMinRole(User $user, StoreUserRole ...$roles): bool {
        $role = $this->getUserRole($user);
        return $role && in_array($role, $roles);
    }
}
```

Create policies: ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy.

After creating everything, run `vendor/bin/pint --dirty` to fix code style.

DO NOT create test files - just the production code.


There's a stale team from a prior session. Let me check if any actual work was done, then restart cleanly.


Bash

echo "=== Task list ===" && ls /Users/fabianwesner/.claude/tasks/shop-build/ 2>&1 | head -30; echo "=== Enums ===" && ls /Users/fabianwesner/Herd/shop/app/Enums 2>&1; echo "=== Services ===" && find /Users/fabianwesner/Herd/shop/app/Services -type f; echo "=== Concerns ===" && find /Users/fabianwesner/Herd/shop/app/Concerns -type f; echo "=== Actions ===" && find /Users/fabianwesner/Herd/shop/app/Actions -type f; echo "=== Support ===" && find /Users/fabianwesner/Herd/shop/app/Support -type f

Check prior team state


=== Task list ===
1.json
2.json
20.json
21.json
22.json
3.json
4.json
5.json
6.json
7.json
8.json
9.json
=== Enums ===
ls: /Users/fabianwesner/Herd/shop/app/Enums: No such file or directory
=== Services ===
=== Concerns ===
/Users/fabianwesner/Herd/shop/app/Concerns/ProfileValidationRules.php
/Users/fabianwesner/Herd/shop/app/Concerns/PasswordValidationRules.php
=== Actions ===
/Users/fabianwesner/Herd/shop/app/Actions/Fortify/ResetUserPassword.php
/Users/fabianwesner/Herd/shop/app/Actions/Fortify/CreateNewUser.php
=== Support ===


No tasks found


No actual code exists. Let me just work within the existing team and start building. First, let me inventory what's in place.


1	<?php
2
3	use Illuminate\Foundation\Application;
4	use Illuminate\Foundation\Configuration\Exceptions;
5	use Illuminate\Foundation\Configuration\Middleware;
6
7	return Application::configure(basePath: dirname(__DIR__))
8	    ->withRouting(
9	        web: __DIR__.'/../routes/web.php',
10	        commands: __DIR__.'/../routes/console.php',
11	        health: '/up',
12	    )
13	    ->withMiddleware(function (Middleware $middleware): void {
14	        //
15	    })
16	    ->withExceptions(function (Exceptions $exceptions): void {
17	        //
18	    })->create();
19

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();


1	<?php
2
3	namespace App\Models;
4
5	// use Illuminate\Contracts\Auth\MustVerifyEmail;
6	use Illuminate\Database\Eloquent\Factories\HasFactory;
7	use Illuminate\Foundation\Auth\User as Authenticatable;
8	use Illuminate\Notifications\Notifiable;
9	use Illuminate\Support\Str;
10	use Laravel\Fortify\TwoFactorAuthenticatable;
11
12	class User extends Authenticatable
13	{
14	    /** @use HasFactory<\Database\Factories\UserFactory> */
15	    use HasFactory, Notifiable, TwoFactorAuthenticatable;
16
17	    /**
18	     * The attributes that are mass assignable.
19	     *
20	     * @var list<string>
21	     */
22	    protected $fillable = [
23	        'name',
24	        'email',
25	        'password',
26	    ];
27
28	    /**
29	     * The attributes that should be hidden for serialization.
30	     *
31	     * @var list<string>
32	     */
33	    protected $hidden = [
34	        'password',
35	        'two_factor_secret',
36	        'two_factor_recovery_codes',
37	        'remember_token',
38	    ];
39
40	    /**
41	     * Get the attributes that should be cast.
42	     *
43	     * @return array<string, string>
44	     */
45	    protected function casts(): array
46	    {
47	        return [
48	            'email_verified_at' => 'datetime',
49	            'password' => 'hashed',
50	        ];
51	    }
52
53	    /**
54	     * Get the user's initials
55	     */
56	    public function initials(): string
57	    {
58	        return Str::of($this->name)
59	            ->explode(' ')
60	            ->take(2)
61	            ->map(fn ($word) => Str::substr($word, 0, 1))
62	            ->implode('');
63	    }
64	}
65

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}


1	<?php
2
3	use Illuminate\Database\Migrations\Migration;
4	use Illuminate\Database\Schema\Blueprint;
5	use Illuminate\Support\Facades\Schema;
6
7	return new class extends Migration
8	{
9	    /**
10	     * Run the migrations.
11	     */
12	    public function up(): void
13	    {
14	        Schema::create('users', function (Blueprint $table) {
15	            $table->id();
16	            $table->string('name');
17	            $table->string('email')->unique();
18	            $table->timestamp('email_verified_at')->nullable();
19	            $table->string('password');
20	            $table->rememberToken();
21	            $table->timestamps();
22	        });
23
24	        Schema::create('password_reset_tokens', function (Blueprint $table) {
25	            $table->string('email')->primary();
26	            $table->string('token');
27	            $table->timestamp('created_at')->nullable();
28	        });
29
30	        Schema::create('sessions', function (Blueprint $table) {
31	            $table->string('id')->primary();
32	            $table->foreignId('user_id')->nullable()->index();
33	            $table->string('ip_address', 45)->nullable();
34	            $table->text('user_agent')->nullable();
35	            $table->longText('payload');
36	            $table->integer('last_activity')->index();
37	        });
38	    }
39
40	    /**
41	     * Reverse the migrations.
42	     */
43	    public function down(): void
44	    {
45	        Schema::dropIfExists('users');
46	        Schema::dropIfExists('password_reset_tokens');
47	        Schema::dropIfExists('sessions');
48	    }
49	};
50

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

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


1	<?php
2
3	use Illuminate\Support\Facades\Route;
4
5	Route::get('/', function () {
6	    return view('welcome');
7	})->name('home');
8
9	Route::view('dashboard', 'dashboard')
10	    ->middleware(['auth', 'verified'])
11	    ->name('dashboard');
12
13	require __DIR__.'/settings.php';
14

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
})->name('home');

Route::view('dashboard', 'dashboard')
    ->middleware(['auth', 'verified'])
    ->name('dashboard');

require __DIR__.'/settings.php';


1	<?php
2
3	namespace App\Providers;
4
5	use Carbon\CarbonImmutable;
6	use Illuminate\Support\Facades\Date;
7	use Illuminate\Support\Facades\DB;
8	use Illuminate\Support\ServiceProvider;
9	use Illuminate\Validation\Rules\Password;
10
11	class AppServiceProvider extends ServiceProvider
12	{
13	    /**
14	     * Register any application services.
15	     */
16	    public function register(): void
17	    {
18	        //
19	    }
20
21	    /**
22	     * Bootstrap any application services.
23	     */
24	    public function boot(): void
25	    {
26	        $this->configureDefaults();
27	    }
28
29	    /**
30	     * Configure default behaviors for production-ready applications.
31	     */
32	    protected function configureDefaults(): void
33	    {
34	        Date::use(CarbonImmutable::class);
35
36	        DB::prohibitDestructiveCommands(
37	            app()->isProduction(),
38	        );
39
40	        Password::defaults(fn (): ?Password => app()->isProduction()
41	            ? Password::min(12)
42	                ->mixedCase()
43	                ->letters()
44	                ->numbers()
45	                ->symbols()
46	                ->uncompromised()
47	            : null
48	        );
49	    }
50	}
51

<?php

namespace App\Providers;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );
    }
}


1	<?php
2
3	namespace App\Providers;
4
5	use App\Actions\Fortify\CreateNewUser;
6	use App\Actions\Fortify\ResetUserPassword;
7	use Illuminate\Cache\RateLimiting\Limit;
8	use Illuminate\Http\Request;
9	use Illuminate\Support\Facades\RateLimiter;
10	use Illuminate\Support\ServiceProvider;
11	use Illuminate\Support\Str;
12	use Laravel\Fortify\Fortify;
13
14	class FortifyServiceProvider extends ServiceProvider
15	{
16	    /**
17	     * Register any application services.
18	     */
19	    public function register(): void
20	    {
21	        //
22	    }
23
24	    /**
25	     * Bootstrap any application services.
26	     */
27	    public function boot(): void
28	    {
29	        $this->configureActions();
30	        $this->configureViews();
31	        $this->configureRateLimiting();
32	    }
33
34	    /**
35	     * Configure Fortify actions.
36	     */
37	    private function configureActions(): void
38	    {
39	        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
40	        Fortify::createUsersUsing(CreateNewUser::class);
41	    }
42
43	    /**
44	     * Configure Fortify views.
45	     */
46	    private function configureViews(): void
47	    {
48	        Fortify::loginView(fn () => view('livewire.auth.login'));
49	        Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
50	        Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
51	        Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
52	        Fortify::registerView(fn () => view('livewire.auth.register'));
53	        Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
54	        Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
55	    }
56
57	    /**
58	     * Configure rate limiting.
59	     */
60	    private function configureRateLimiting(): void
61	    {
62	        RateLimiter::for('two-factor', function (Request $request) {
63	            return Limit::perMinute(5)->by($request->session()->get('login.id'));
64	        });
65
66	        RateLimiter::for('login', function (Request $request) {
67	            $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
68
69	            return Limit::perMinute(5)->by($throttleKey);
70	        });
71	    }
72	}
73

<?php

namespace App\Providers;

use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;

class FortifyServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureActions();
        $this->configureViews();
        $this->configureRateLimiting();
    }

    /**
     * Configure Fortify actions.
     */
    private function configureActions(): void
    {
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
        Fortify::createUsersUsing(CreateNewUser::class);
    }

    /**
     * Configure Fortify views.
     */
    private function configureViews(): void
    {
        Fortify::loginView(fn () => view('livewire.auth.login'));
        Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
        Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
        Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
        Fortify::registerView(fn () => view('livewire.auth.register'));
        Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
        Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
    }

    /**
     * Configure rate limiting.
     */
    private function configureRateLimiting(): void
    {
        RateLimiter::for('two-factor', function (Request $request) {
            return Limit::perMinute(5)->by($request->session()->get('login.id'));
        });

        RateLimiter::for('login', function (Request $request) {
            $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());

            return Limit::perMinute(5)->by($throttleKey);
        });
    }
}


TaskCreate

Phase 1: Foundation — migrations, enums, models, tenancy, auth

Create config updates, all foundation migrations (organizations, stores, store_domains, store_users, store_settings, customers, customer_addresses), enums, models with BelongsToStore trait + StoreScope, ResolveStore middleware, admin auth login/logout, customer auth provider.


Task #1 created successfully: Phase 1: Foundation — migrations, enums, models, tenancy, auth


Phase 2: Catalog — products, variants, inventory, collections, media

Migrations + models + services for products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media. Includes ProductService, InventoryService, HandleGenerator.


Task #2 created successfully: Phase 2: Catalog — products, variants, inventory, collections, media


Phase 3: Themes + storefront layout

Theme, theme_files, theme_settings, pages, navigation_menus, navigation_items migrations and models. Blade storefront layout + Livewire components (Home, Collections, Products, Cart, Search, Pages).


Task #3 created successfully: Phase 3: Themes + storefront layout

Phase 3: Themes + storefront layout


Phase 4: Cart, checkout, discounts, shipping, taxes

Migrations + models for carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Services: CartService, PricingService, ShippingService, TaxService, DiscountService.


Task #4 created successfully: Phase 4: Cart, checkout, discounts, shipping, taxes


Phase 5: Payments, orders, fulfillment

Migrations + models for orders, order_lines, payments, refunds, fulfillments, fulfillment_lines. MockPaymentProvider, OrderService, CheckoutCompletionService, FulfillmentService, RefundService.


Task #5 created successfully: Phase 5: Payments, orders, fulfillment

Phase 5: Payments, orders, fulfillment


Phase 6: Customer accounts

Customer login, register, forgot password, dashboard, orders list, order detail, addresses. Livewire components for storefront account.


Task #6 created successfully: Phase 6: Customer accounts

Phase 6: Customer accounts


Phase 7: Admin panel

Admin Dashboard, Products (list/create/edit), Collections, Inventory, Orders, Customers, Discounts, Settings (shipping, taxes, general), Pages, Navigation, Themes.


Task #7 created successfully: Phase 7: Admin panel

Phase 7: Admin panel


Phase 8-10: Search (FTS), analytics, apps/webhooks stubs

SQLite FTS5 search index, analytics event tracking + daily rollups, apps/installations/webhook_subscriptions/webhook_deliveries tables and admin UI.


Task #8 created successfully: Phase 8-10: Search (FTS), analytics, apps/webhooks stubs


Phase 11-12: Seeders, Pest tests, Playwright E2E

Database seeders (demo store, demo products, demo orders). Pest unit + feature tests for each module. Playwright browser tests for customer journey + admin operations.


Task #9 created successfully: Phase 11-12: Seeders, Pest tests, Playwright E2E


Updated task #1 owner, status


Starting Phase 1 — Foundation. I'll write the migrations, enums, and core models first.


Bash

Create directory structure


(Bash completed with no output)


ls config/ && cat config/auth.php | head -80

Check config


app.php
auth.php
cache.php
database.php
filesystems.php
fortify.php
logging.php
mail.php
queue.php
services.php
session.php
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage


1	<?php
2
3	return [
4
5	    /*
6	    |--------------------------------------------------------------------------
7	    | Authentication Defaults
8	    |--------------------------------------------------------------------------
9	    |
10	    | This option defines the default authentication "guard" and password
11	    | reset "broker" for your application. You may change these values
12	    | as required, but they're a perfect start for most applications.
13	    |
14	    */
15
16	    'defaults' => [
17	        'guard' => env('AUTH_GUARD', 'web'),
18	        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
19	    ],
20
21	    /*
22	    |--------------------------------------------------------------------------
23	    | Authentication Guards
24	    |--------------------------------------------------------------------------
25	    |
26	    | Next, you may define every authentication guard for your application.
27	    | Of course, a great default configuration has been defined for you
28	    | which utilizes session storage plus the Eloquent user provider.
29	    |
30	    | All authentication guards have a user provider, which defines how the
31	    | users are actually retrieved out of your database or other storage
32	    | system used by the application. Typically, Eloquent is utilized.
33	    |
34	    | Supported: "session"
35	    |
36	    */
37
38	    'guards' => [
39	        'web' => [
40	            'driver' => 'session',
41	            'provider' => 'users',
42	        ],
43	    ],
44
45	    /*
46	    |--------------------------------------------------------------------------
47	    | User Providers
48	    |--------------------------------------------------------------------------
49	    |
50	    | All authentication guards have a user provider, which defines how the
51	    | users are actually retrieved out of your database or other storage
52	    | system used by the application. Typically, Eloquent is utilized.
53	    |
54	    | If you have multiple user tables or models you may configure multiple
55	    | providers to represent the model / table. These providers may then
56	    | be assigned to any extra authentication guards you have defined.
57	    |
58	    | Supported: "database", "eloquent"
59	    |
60	    */
61
62	    'providers' => [
63	        'users' => [
64	            'driver' => 'eloquent',
65	            'model' => env('AUTH_MODEL', App\Models\User::class),
66	        ],
67
68	        // 'users' => [
69	        //     'driver' => 'database',
70	        //     'table' => 'users',
71	        // ],
72	    ],
73
74	    /*
75	    |--------------------------------------------------------------------------
76	    | Resetting Passwords
77	    |--------------------------------------------------------------------------
78	    |
79	    | These configuration options specify the behavior of Laravel's password
80	    | reset functionality, including the table utilized for token storage
81	    | and the user provider that is invoked to actually retrieve users.
82	    |
83	    | The expiry time is the number of minutes that each reset token will be
84	    | considered valid. This security feature keeps tokens short-lived so
85	    | they have less time to be guessed. You may change this as needed.
86	    |
87	    | The throttle setting is the number of seconds a user must wait before
88	    | generating more password reset tokens. This prevents the user from
89	    | quickly generating a very large amount of password reset tokens.
90	    |
91	    */
92
93	    'passwords' => [
94	        'users' => [
95	            'provider' => 'users',
96	            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
97	            'expire' => 60,
98	            'throttle' => 60,
99	        ],
100	    ],
101
102	    /*
103	    |--------------------------------------------------------------------------
104	    | Password Confirmation Timeout
105	    |--------------------------------------------------------------------------
106	    |
107	    | Here you may define the number of seconds before a password confirmation
108	    | window expires and users are asked to re-enter their password via the
109	    | confirmation screen. By default, the timeout lasts for three hours.
110	    |
111	    */
112
113	    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
114
115	];
116

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];


Now I'll add the customer guard, create the enums, the foundation migrations, models, middleware, and auth components. This is a lot of files — I'll do them in parallel.


Write

<?php

return [

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];


The file /Users/fabianwesner/Herd/shop/config/auth.php has been updated successfully.

<?php

return [

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];

-    | This option defines the default authentication "guard" and password

-    | reset "broker" for your application. You may change these values

-    | as required, but they're a perfect start for most applications.

-    | Next, you may define every authentication guard for your application.

-    | Of course, a great default configuration has been defined for you

-    | which utilizes session storage plus the Eloquent user provider.

-    | All authentication guards have a user provider, which defines how the

-    | users are actually retrieved out of your database or other storage

-    | system used by the application. Typically, Eloquent is utilized.

-    | If you have multiple user tables or models you may configure multiple

-    | providers to represent the model / table. These providers may then

-    | be assigned to any extra authentication guards you have defined.

-    | Supported: "database", "eloquent"

+            'model' => App\Models\Customer::class,

-    | These configuration options specify the behavior of Laravel's password

-    | reset functionality, including the table utilized for token storage

-    | and the user provider that is invoked to actually retrieve users.

-    | The expiry time is the number of minutes that each reset token will be

-    | considered valid. This security feature keeps tokens short-lived so

-    | they have less time to be guessed. You may change this as needed.

-    | The throttle setting is the number of seconds a user must wait before

-    | generating more password reset tokens. This prevents the user from

-    | quickly generating a very large amount of password reset tokens.

+            'table' => 'customer_password_reset_tokens',

-    | Here you may define the number of seconds before a password confirmation

-    | window expires and users are asked to re-enter their password via the

-    | confirmation screen. By default, the timeout lasts for three hours.

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];


<?php

namespace App\Enums;

enum StoreStatus: string
{
    case Active = 'active';
    case Suspended = 'suspended';
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Enums/StoreStatus.php

<?php

namespace App\Enums;

enum StoreStatus: string
{
    case Active = 'active';
    case Suspended = 'suspended';
}