Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

   INFO  Discovering packages.

  laravel/boost ......................................................... DONE
  laravel/fortify ....................................................... DONE
  laravel/mcp ........................................................... DONE
  laravel/pail .......................................................... DONE
  laravel/roster ........................................................ DONE
  laravel/sail .......................................................... DONE
  laravel/sanctum ....................................................... DONE
  laravel/tinker ........................................................ DONE
  livewire/flux ......................................................... DONE
  livewire/livewire ..................................................... DONE
  nesbot/carbon ......................................................... DONE
  nunomaduro/collision .................................................. DONE
  nunomaduro/termwind ................................................... DONE
  pestphp/pest-plugin-laravel ........................................... DONE

87 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan vendor:publish --tag=laravel-assets --ansi --force

   INFO  No publishable resources for tag [laravel-assets].

Found 3 security vulnerability advisories affecting 2 packages.
Run "composer audit" for a full list of advisories.
Using version ^4.3 for laravel/sanctum


Your mission is to implement an entire shop system based on the specifications im specs/*. Distribute work to sub-agents. Keep the context of the main agent clean. You have to build in one go without…


Bash

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" --no-interaction 2>&1 | tail -10

Publish Sanctum migrations


   INFO  Publishing assets.

  Copying directory [vendor/laravel/sanctum/database/migrations] to [database/migrations]  DONE
  Copying file [vendor/laravel/sanctum/config/sanctum.php] to [config/sanctum.php]  DONE


7:## Conventions
23:## Epic 1 - Foundation and Multi-Store Tenancy
25:### organizations
43:### stores
68:### store_domains
90:### users
119:### store_users
139:### store_settings
153:## Epic 2 - Catalog, Collections, Inventory, and Media
155:### products
187:### product_options
205:### product_option_values
223:### product_variants
256:### variant_option_values
275:### inventory_items
297:### collections
321:### collection_products
340:### product_media
367:## Epic 3 - Themes, Storefront, and Localization
369:### themes
391:### theme_files
411:### theme_settings
423:### pages
447:### navigation_menus
467:### navigation_items
490:## Epic 4 - Search
492:### search_settings
505:### search_queries
526:## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
528:### carts
553:### cart_lines
577:### checkouts
612:### shipping_zones
632:### shipping_rates
658:### tax_settings
672:### discounts
704:## Epic 6 - Payments, Orders, Fulfillment, and Customers
706:### customers
730:### customer_addresses
751:### orders
790:### order_lines
818:### payments
846:### refunds
869:### fulfillments
892:### fulfillment_lines
912:## Epic 7 - Analytics
914:### analytics_events
943:### analytics_daily
969:## Epic 8 - Apps and Webhooks
971:### apps
988:### app_installations
1009:### oauth_clients
1028:### oauth_tokens
1048:### webhook_subscriptions
1070:### webhook_deliveries
1094:## Laravel Framework Tables
1098:### sessions
1109:### cache
1117:### cache_locks
1125:### jobs
1137:### job_batches
1152:### failed_jobs
1164:### personal_access_tokens
1179:### password_reset_tokens
1189:### customer_password_reset_tokens
1202:## Entity Relationship Diagrams
1204:### Epic 1 - Foundation and Tenancy
1251:### Epic 2 - Catalog, Collections, Inventory, and Media
1318:### Epic 3 - Themes, Storefront, and Localization
1367:### Epics 5-6 - Cart, Checkout, Orders, and Payments
1428:### Epics 7-8 - Analytics, Apps, and Webhooks
1482:## SQLite-Specific Configuration Requirements
1486:### Required PRAGMA Settings
1496:### Laravel Configuration Requirements
1507:## Migration Dependency Graph
1641:### Batch Details
1653:## Index Strategy Rationale
1669:## Table Count Summary


1	# 01 - Database Schema Specification
2
3	> E-commerce platform - SQLite database schema for Laravel 12
4
5	---
6
7	## Conventions
8
9	| Convention | Detail |
10	|---|---|
11	| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
12	| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
13	| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
14	| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
15	| JSON data | Text column - Laravel casts to/from JSON at the application layer |
16	| Enumerations | Check constraints on text columns |
17	| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
18	| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
19	| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |
20
21	---
22
23	## Epic 1 - Foundation and Multi-Store Tenancy
24
25	### organizations
26
27	Billing entity that owns one or more stores.
28
29	| Column | Type | Nullable | Default | Constraints | Notes |
30	|---|---|---|---|---|---|
31	| id | INTEGER | No | autoincrement | PRIMARY KEY | |
32	| name | TEXT | No | - | - | Organization display name |
33	| billing_email | TEXT | No | - | - | Primary billing contact |
34	| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
35	| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
36
37	**Indexes:**
38
39	- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email
40
41	---
42
43	### stores
44
45	Individual storefront belonging to an organization.
46
47	| Column | Type | Nullable | Default | Constraints | Notes |
48	|---|---|---|---|---|---|
49	| id | INTEGER | No | autoincrement | PRIMARY KEY | |
50	| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
51	| name | TEXT | No | - | - | Store display name |
52	| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
53	| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
54	| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
55	| default_locale | TEXT | No | 'en' | - | IETF locale tag |
56	| timezone | TEXT | No | 'UTC' | - | IANA timezone |
57	| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
58	| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
59
60	**Indexes:**
61
62	- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
63	- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
64	- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status
65
66	---
67
68	### store_domains
69
70	Custom domains attached to a store.
71
72	| Column | Type | Nullable | Default | Constraints | Notes |
73	|---|---|---|---|---|---|
74	| id | INTEGER | No | autoincrement | PRIMARY KEY | |
75	| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
76	| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
77	| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
78	| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
79	| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
80	| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
81
82	**Indexes:**
83
84	- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
85	- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
86	- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain
87
88	---
89
90	### users
91
92	Admin/staff users who manage stores (not storefront customers).
93
94	| Column | Type | Nullable | Default | Constraints | Notes |
95	|---|---|---|---|---|---|
96	| id | INTEGER | No | autoincrement | PRIMARY KEY | |
97	| email | TEXT | No | - | UNIQUE per table | Login email |
98	| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
99	| name | TEXT | No | - | - | Display name |
100	| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
101	| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
102	| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
103	| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
104	| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
105	| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
106	| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
107	| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
108	| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
109
110	**Indexes:**
111
112	- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
113	- `idx_users_status` on (`status`) - Filtering users by active/disabled
114
115	**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.
116
117	---
118
119	### store_users
120
121	Pivot table linking users to stores with a role. Uses composite primary key.
122
123	| Column | Type | Nullable | Default | Constraints | Notes |
124	|---|---|---|---|---|---|
125	| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
126	| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
127	| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
128	| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
129
130	**Primary Key:** (`store_id`, `user_id`)
131
132	**Indexes:**
133
134	- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
135	- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store
136
137	---
138
139	### store_settings
140
141	Single-row-per-store JSON settings bag.
142
143	| Column | Type | Nullable | Default | Constraints | Notes |
144	|---|---|---|---|---|---|
145	| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
146	| settings_json | TEXT | No | '{}' | - | JSON settings object |
147	| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
148
149	**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.
150
151	---
152
153	## Epic 2 - Catalog, Collections, Inventory, and Media
154
155	### products

# 01 - Database Schema Specification

> E-commerce platform - SQLite database schema for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
| JSON data | Text column - Laravel casts to/from JSON at the application layer |
| Enumerations | Check constraints on text columns |
| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | Organization display name |
| billing_email | TEXT | No | - | - | Primary billing contact |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email

---

### stores

Individual storefront belonging to an organization.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
| name | TEXT | No | - | - | Store display name |
| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| default_locale | TEXT | No | 'en' | - | IETF locale tag |
| timezone | TEXT | No | 'UTC' | - | IANA timezone |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status

---

### store_domains

Custom domains attached to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain

---

### users

Admin/staff users who manage stores (not storefront customers).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| email | TEXT | No | - | UNIQUE per table | Login email |
| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
| name | TEXT | No | - | - | Display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
- `idx_users_status` on (`status`) - Filtering users by active/disabled

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.

---

### store_users

Pivot table linking users to stores with a role. Uses composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Primary Key:** (`store_id`, `user_id`)

**Indexes:**

- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store

---

### store_settings

Single-row-per-store JSON settings bag.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products


Building Phase 1 foundation now. I'll batch create migrations, enums, models, middleware, and the tenancy trait.


Write

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('status')->default('active')->after('password');
            $table->timestamp('last_login_at')->nullable()->after('status');
            $table->index('status', 'idx_users_status');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropIndex('idx_users_status');
            $table->dropColumn(['status', 'last_login_at']);
        });
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000000_update_users_for_shop.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('status')->default('active')->after('password');
            $table->timestamp('last_login_at')->nullable()->after('status');
            $table->index('status', 'idx_users_status');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropIndex('idx_users_status');
            $table->dropColumn(['status', 'last_login_at']);
        });
    }
};


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('billing_email');
            $table->timestamps();
            $table->index('billing_email', 'idx_organizations_billing_email');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('organizations');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000001_create_organizations_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('billing_email');
            $table->timestamps();
            $table->index('billing_email', 'idx_organizations_billing_email');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('organizations');
    }
};


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('stores', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete();
            $table->string('name');
            $table->string('handle')->unique();
            $table->string('status')->default('active');
            $table->string('default_currency', 3)->default('USD');
            $table->string('default_locale', 10)->default('en');
            $table->string('timezone', 40)->default('UTC');
            $table->timestamps();
            $table->index('organization_id', 'idx_stores_organization_id');
            $table->index('status', 'idx_stores_status');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('stores');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000002_create_stores_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('stores', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete();
            $table->string('name');
            $table->string('handle')->unique();
            $table->string('status')->default('active');
            $table->string('default_currency', 3)->default('USD');
            $table->string('default_locale', 10)->default('en');
            $table->string('timezone', 40)->default('UTC');
            $table->timestamps();
            $table->index('organization_id', 'idx_stores_organization_id');
            $table->index('status', 'idx_stores_status');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('stores');
    }
};


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_domains', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('hostname')->unique();
            $table->string('type')->default('storefront');
            $table->boolean('is_primary')->default(false);
            $table->string('tls_mode')->default('managed');
            $table->timestamp('created_at')->nullable();
            $table->index('store_id', 'idx_store_domains_store_id');
            $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_domains');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000003_create_store_domains_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_domains', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('hostname')->unique();
            $table->string('type')->default('storefront');
            $table->boolean('is_primary')->default(false);
            $table->string('tls_mode')->default('managed');
            $table->timestamp('created_at')->nullable();
            $table->index('store_id', 'idx_store_domains_store_id');
            $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_domains');
    }
};


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_users', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
            $table->string('role')->default('staff');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'user_id']);
            $table->index('user_id', 'idx_store_users_user_id');
            $table->index(['store_id', 'role'], 'idx_store_users_role');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_users');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000004_create_store_users_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_users', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
            $table->string('role')->default('staff');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'user_id']);
            $table->index('user_id', 'idx_store_users_user_id');
            $table->index(['store_id', 'role'], 'idx_store_users_role');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_users');
    }
};


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_settings');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000005_create_store_settings_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('store_settings');
    }
};


<?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';
}


<?php

namespace App\Enums;

enum StoreUserRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Staff = 'staff';
    case Support = 'support';

    public function canManageStore(): bool
    {
        return in_array($this, [self::Owner, self::Admin], true);
    }

    public function canWriteProducts(): bool
    {
        return in_array($this, [self::Owner, self::Admin, self::Staff], true);
    }

    public function canProcessRefunds(): bool
    {
        return in_array($this, [self::Owner, self::Admin], true);
    }
}


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

<?php

namespace App\Enums;

enum StoreUserRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Staff = 'staff';
    case Support = 'support';

    public function canManageStore(): bool
    {
        return in_array($this, [self::Owner, self::Admin], true);
    }

    public function canWriteProducts(): bool
    {
        return in_array($this, [self::Owner, self::Admin, self::Staff], true);
    }

    public function canProcessRefunds(): bool
    {
        return in_array($this, [self::Owner, self::Admin], true);
    }
}


<?php

namespace App\Enums;

enum StoreDomainType: string
{
    case Storefront = 'storefront';
    case Admin = 'admin';
    case Api = 'api';
}


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

<?php

namespace App\Enums;

enum StoreDomainType: string
{
    case Storefront = 'storefront';
    case Admin = 'admin';
    case Api = 'api';
}


<?php

namespace App\Enums;

enum UserStatus: string
{
    case Active = 'active';
    case Disabled = 'disabled';
}


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

<?php

namespace App\Enums;

enum UserStatus: string
{
    case Active = 'active';
    case Disabled = 'disabled';
}


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Organization extends Model
{
    /** @use HasFactory<\Database\Factories\OrganizationFactory> */
    use HasFactory;

    protected $fillable = [
        'name',
        'billing_email',
    ];

    public function stores(): HasMany
    {
        return $this->hasMany(Store::class);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/Organization.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Organization extends Model
{
    /** @use HasFactory<\Database\Factories\OrganizationFactory> */
    use HasFactory;

    protected $fillable = [
        'name',
        'billing_email',
    ];

    public function stores(): HasMany
    {
        return $this->hasMany(Store::class);
    }
}


<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    /** @use HasFactory<\Database\Factories\StoreFactory> */
    use HasFactory;

    protected $fillable = [
        'organization_id',
        'name',
        'handle',
        'status',
        'default_currency',
        'default_locale',
        'timezone',
    ];

    protected function casts(): array
    {
        return [
            'status' => StoreStatus::class,
        ];
    }

    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }

    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role', 'created_at');
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isSuspended(): bool
    {
        return $this->status === StoreStatus::Suspended;
    }

    public function primaryStorefrontDomain(): ?StoreDomain
    {
        return $this->domains()->where('type', 'storefront')->orderByDesc('is_primary')->first();
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/Store.php

<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    /** @use HasFactory<\Database\Factories\StoreFactory> */
    use HasFactory;

    protected $fillable = [
        'organization_id',
        'name',
        'handle',
        'status',
        'default_currency',
        'default_locale',
        'timezone',
    ];

    protected function casts(): array
    {
        return [
            'status' => StoreStatus::class,
        ];
    }

    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }

    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role', 'created_at');
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isSuspended(): bool
    {
        return $this->status === StoreStatus::Suspended;
    }

    public function primaryStorefrontDomain(): ?StoreDomain
    {
        return $this->domains()->where('type', 'storefront')->orderByDesc('is_primary')->first();
    }
}


<?php

namespace App\Models;

use App\Enums\StoreDomainType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreDomain extends Model
{
    /** @use HasFactory<\Database\Factories\StoreDomainFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'hostname',
        'type',
        'is_primary',
        'tls_mode',
        'created_at',
    ];

    protected function casts(): array
    {
        return [
            'type' => StoreDomainType::class,
            'is_primary' => 'boolean',
            'created_at' => 'datetime',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/StoreDomain.php

<?php

namespace App\Models;

use App\Enums\StoreDomainType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreDomain extends Model
{
    /** @use HasFactory<\Database\Factories\StoreDomainFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'hostname',
        'type',
        'is_primary',
        'tls_mode',
        'created_at',
    ];

    protected function casts(): array
    {
        return [
            'type' => StoreDomainType::class,
            'is_primary' => 'boolean',
            'created_at' => 'datetime',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Relations\Pivot;

class StoreUser extends Pivot
{
    public $incrementing = false;

    protected $table = 'store_users';

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'user_id',
        'role',
        'created_at',
    ];

    protected function casts(): array
    {
        return [
            'role' => StoreUserRole::class,
            'created_at' => 'datetime',
        ];
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/StoreUser.php

<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Relations\Pivot;

class StoreUser extends Pivot
{
    public $incrementing = false;

    protected $table = 'store_users';

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'user_id',
        'role',
        'created_at',
    ];

    protected function casts(): array
    {
        return [
            'role' => StoreUserRole::class,
            'created_at' => 'datetime',
        ];
    }
}


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreSettings extends Model
{
    /** @use HasFactory<\Database\Factories\StoreSettingsFactory> */
    use HasFactory;

    protected $table = 'store_settings';

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'settings_json',
        'updated_at',
    ];

    protected function casts(): array
    {
        return [
            'settings_json' => 'array',
            'updated_at' => 'datetime',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/StoreSettings.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreSettings extends Model
{
    /** @use HasFactory<\Database\Factories\StoreSettingsFactory> */
    use HasFactory;

    protected $table = 'store_settings';

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    public $timestamps = false;

    protected $fillable = [
        'store_id',
        'settings_json',
        'updated_at',
    ];

    protected function casts(): array
    {
        return [
            'settings_json' => 'array',
            'updated_at' => 'datetime',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


<?php

namespace App\Models\Scopes;

use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class StoreScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('current_store')) {
            return;
        }

        $store = app('current_store');

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

        $builder->where($model->getTable().'.store_id', $store->id);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/Scopes/StoreScope.php

<?php

namespace App\Models\Scopes;

use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class StoreScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('current_store')) {
            return;
        }

        $store = app('current_store');

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

        $builder->where($model->getTable().'.store_id', $store->id);
    }
}


<?php

namespace App\Models\Concerns;

use App\Models\Scopes\StoreScope;
use App\Models\Store;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToStore
{
    protected static function bootBelongsToStore(): void
    {
        static::addGlobalScope(new StoreScope);

        static::creating(function ($model): void {
            if (! $model->getAttribute('store_id') && app()->bound('current_store')) {
                $store = app('current_store');
                if ($store instanceof Store) {
                    $model->setAttribute('store_id', $store->id);
                }
            }
        });
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/Concerns/BelongsToStore.php

<?php

namespace App\Models\Concerns;

use App\Models\Scopes\StoreScope;
use App\Models\Store;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToStore
{
    protected static function bootBelongsToStore(): void
    {
        static::addGlobalScope(new StoreScope);

        static::creating(function ($model): void {
            if (! $model->getAttribute('store_id') && app()->bound('current_store')) {
                $store = app('current_store');
                if ($store instanceof Store) {
                    $model->setAttribute('store_id', $store->id);
                }
            }
        });
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


<?php

namespace App\Http\Middleware;

use App\Enums\StoreStatus;
use App\Models\Store;
use App\Models\StoreDomain;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ResolveStore
{
    public function handle(Request $request, Closure $next, string $context = 'storefront'): Response
    {
        $store = match ($context) {
            'admin' => $this->resolveAdmin($request),
            default => $this->resolveStorefront($request),
        };

        if (! $store) {
            if ($context === 'admin') {
                abort(404);
            }
            throw new NotFoundHttpException('Store not found for hostname '.$request->getHost());
        }

        if ($context === 'storefront' && $store->isSuspended()) {
            abort(503, 'Store temporarily unavailable');
        }

        app()->instance('current_store', $store);
        $request->attributes->set('current_store', $store);

        return $next($request);
    }

    protected function resolveStorefront(Request $request): ?Store
    {
        $hostname = strtolower($request->getHost());

        $storeId = Cache::remember("store_domain:{$hostname}", now()->addMinutes(5), function () use ($hostname): ?int {
            $domain = StoreDomain::query()->where('hostname', $hostname)->first();

            return $domain?->store_id;
        });

        if (! $storeId) {
            return null;
        }

        return Store::query()->find($storeId);
    }

    protected function resolveAdmin(Request $request): ?Store
    {
        $user = $request->user();
        $storeId = $request->session()->get('current_store_id');

        if (! $storeId && $user) {
            $storeId = $user->stores()->value('stores.id');
            if ($storeId) {
                $request->session()->put('current_store_id', $storeId);
            }
        }

        if (! $storeId) {
            return null;
        }

        $store = Store::query()->find($storeId);

        if (! $store) {
            return null;
        }

        if ($user && ! $user->stores()->whereKey($store->id)->exists()) {
            abort(403, 'You are not a member of this store');
        }

        return $store;
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Http/Middleware/ResolveStore.php

<?php

namespace App\Http\Middleware;

use App\Enums\StoreStatus;
use App\Models\Store;
use App\Models\StoreDomain;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ResolveStore
{
    public function handle(Request $request, Closure $next, string $context = 'storefront'): Response
    {
        $store = match ($context) {
            'admin' => $this->resolveAdmin($request),
            default => $this->resolveStorefront($request),
        };

        if (! $store) {
            if ($context === 'admin') {
                abort(404);
            }
            throw new NotFoundHttpException('Store not found for hostname '.$request->getHost());
        }

        if ($context === 'storefront' && $store->isSuspended()) {
            abort(503, 'Store temporarily unavailable');
        }

        app()->instance('current_store', $store);
        $request->attributes->set('current_store', $store);

        return $next($request);
    }

    protected function resolveStorefront(Request $request): ?Store
    {
        $hostname = strtolower($request->getHost());

        $storeId = Cache::remember("store_domain:{$hostname}", now()->addMinutes(5), function () use ($hostname): ?int {
            $domain = StoreDomain::query()->where('hostname', $hostname)->first();

            return $domain?->store_id;
        });

        if (! $storeId) {
            return null;
        }

        return Store::query()->find($storeId);
    }

    protected function resolveAdmin(Request $request): ?Store
    {
        $user = $request->user();
        $storeId = $request->session()->get('current_store_id');

        if (! $storeId && $user) {
            $storeId = $user->stores()->value('stores.id');
            if ($storeId) {
                $request->session()->put('current_store_id', $storeId);
            }
        }

        if (! $storeId) {
            return null;
        }

        $store = Store::query()->find($storeId);

        if (! $store) {
            return null;
        }

        if ($user && ! $user->stores()->whereKey($store->id)->exists()) {
            abort(403, 'You are not a member of this store');
        }

        return $store;
    }
}


<?php

namespace Database\Factories;

use App\Models\Organization;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Organization>
 */
class OrganizationFactory extends Factory
{
    protected $model = Organization::class;

    public function definition(): array
    {
        return [
            'name' => $this->faker->company(),
            'billing_email' => $this->faker->unique()->safeEmail(),
        ];
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/database/factories/OrganizationFactory.php

<?php

namespace Database\Factories;

use App\Models\Organization;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Organization>
 */
class OrganizationFactory extends Factory
{
    protected $model = Organization::class;

    public function definition(): array
    {
        return [
            'name' => $this->faker->company(),
            'billing_email' => $this->faker->unique()->safeEmail(),
        ];
    }
}


<?php

namespace Database\Factories;

use App\Enums\StoreStatus;
use App\Models\Organization;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends Factory<Store>
 */
class StoreFactory extends Factory
{
    protected $model = Store::class;

    public function definition(): array
    {
        $name = $this->faker->unique()->company();

        return [
            'organization_id' => Organization::factory(),
            'name' => $name,
            'handle' => Str::slug($name.'-'.$this->faker->unique()->numberBetween(1000, 99999)),
            'status' => StoreStatus::Active,
            'default_currency' => 'USD',
            'default_locale' => 'en',
            'timezone' => 'UTC',
        ];
    }

    public function suspended(): static
    {
        return $this->state(['status' => StoreStatus::Suspended]);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/database/factories/StoreFactory.php

<?php

namespace Database\Factories;

use App\Enums\StoreStatus;
use App\Models\Organization;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends Factory<Store>
 */
class StoreFactory extends Factory
{
    protected $model = Store::class;

    public function definition(): array
    {
        $name = $this->faker->unique()->company();

        return [
            'organization_id' => Organization::factory(),
            'name' => $name,
            'handle' => Str::slug($name.'-'.$this->faker->unique()->numberBetween(1000, 99999)),
            'status' => StoreStatus::Active,
            'default_currency' => 'USD',
            'default_locale' => 'en',
            'timezone' => 'UTC',
        ];
    }

    public function suspended(): static
    {
        return $this->state(['status' => StoreStatus::Suspended]);
    }
}


<?php

namespace Database\Factories;

use App\Enums\StoreDomainType;
use App\Models\Store;
use App\Models\StoreDomain;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<StoreDomain>
 */
class StoreDomainFactory extends Factory
{
    protected $model = StoreDomain::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'hostname' => $this->faker->unique()->domainName(),
            'type' => StoreDomainType::Storefront,
            'is_primary' => true,
            'tls_mode' => 'managed',
            'created_at' => now(),
        ];
    }

    public function admin(): static
    {
        return $this->state(['type' => StoreDomainType::Admin]);
    }

    public function storefront(): static
    {
        return $this->state(['type' => StoreDomainType::Storefront]);
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/database/factories/StoreDomainFactory.php

<?php

namespace Database\Factories;

use App\Enums\StoreDomainType;
use App\Models\Store;
use App\Models\StoreDomain;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<StoreDomain>
 */
class StoreDomainFactory extends Factory
{
    protected $model = StoreDomain::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'hostname' => $this->faker->unique()->domainName(),
            'type' => StoreDomainType::Storefront,
            'is_primary' => true,
            'tls_mode' => 'managed',
            'created_at' => now(),
        ];
    }

    public function admin(): static
    {
        return $this->state(['type' => StoreDomainType::Admin]);
    }

    public function storefront(): static
    {
        return $this->state(['type' => StoreDomainType::Storefront]);
    }
}


<?php

namespace Database\Factories;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<StoreSettings>
 */
class StoreSettingsFactory extends Factory
{
    protected $model = StoreSettings::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'settings_json' => [
                'announcement_bar' => 'Free shipping on orders over $50',
                'support_email' => 'support@example.com',
            ],
            'updated_at' => now(),
        ];
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/database/factories/StoreSettingsFactory.php

<?php

namespace Database\Factories;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<StoreSettings>
 */
class StoreSettingsFactory extends Factory
{
    protected $model = StoreSettings::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'settings_json' => [
                'announcement_bar' => 'Free shipping on orders over $50',
                'support_email' => 'support@example.com',
            ],
            'updated_at' => now(),
        ];
    }
}


1	<?php
2
3	namespace Database\Factories;
4
5	use Illuminate\Database\Eloquent\Factories\Factory;
6	use Illuminate\Support\Facades\Hash;
7	use Illuminate\Support\Str;
8
9	/**
10	 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
11	 */
12	class UserFactory extends Factory
13	{
14	    /**
15	     * The current password being used by the factory.
16	     */
17	    protected static ?string $password;
18
19	    /**
20	     * Define the model's default state.
21	     *
22	     * @return array<string, mixed>
23	     */
24	    public function definition(): array
25	    {
26	        return [
27	            'name' => fake()->name(),
28	            'email' => fake()->unique()->safeEmail(),
29	            'email_verified_at' => now(),
30	            'password' => static::$password ??= Hash::make('password'),
31	            'remember_token' => Str::random(10),
32	            'two_factor_secret' => null,
33	            'two_factor_recovery_codes' => null,
34	            'two_factor_confirmed_at' => null,
35	        ];
36	    }
37
38	    /**
39	     * Indicate that the model's email address should be unverified.
40	     */
41	    public function unverified(): static
42	    {
43	        return $this->state(fn (array $attributes) => [
44	            'email_verified_at' => null,
45	        ]);
46	    }
47
48	    /**
49	     * Indicate that the model has two-factor authentication configured.
50	     */
51	    public function withTwoFactor(): static
52	    {
53	        return $this->state(fn (array $attributes) => [
54	            'two_factor_secret' => encrypt('secret'),
55	            'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
56	            'two_factor_confirmed_at' => now(),
57	        ]);
58	    }
59	}
60

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
 */
class UserFactory extends Factory
{
    /**
     * The current password being used by the factory.
     */
    protected static ?string $password;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'password' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
            'two_factor_secret' => null,
            'two_factor_recovery_codes' => null,
            'two_factor_confirmed_at' => null,
        ];
    }

    /**
     * Indicate that the model's email address should be unverified.
     */
    public function unverified(): static
    {
        return $this->state(fn (array $attributes) => [
            'email_verified_at' => null,
        ]);
    }

    /**
     * Indicate that the model has two-factor authentication configured.
     */
    public function withTwoFactor(): static
    {
        return $this->state(fn (array $attributes) => [
            'two_factor_secret' => encrypt('secret'),
            'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
            'two_factor_confirmed_at' => now(),
        ]);
    }
}


Phase 6: Customer Accounts

Customer auth (guard + provider from Phase 1). Livewire components: Storefront\\Account\\Auth\\Login, Register, Dashboard, Orders\\Index, Orders\\Show, Addresses\\Index. Routes /account, /account/login, /account/register, /account/orders, /account/addresses. Logout action. Tests CustomerAccountTest, AddressManagementTest.

Phase 7: Admin Panel

Admin layout (Sidebar/TopBar/Breadcrumbs/Flux). Livewire components: Admin\\Dashboard (KPIs, chart, recent orders), Admin\\Products\\Index + Form (shared form for create/edit), Admin\\Orders\\Index + Show (+ fulfillment/refund modals), Admin\\Collections\\Index + Form, Admin\\Customers\\Index + Show, Admin\\Discounts\\Index + Form, Admin\\Settings\\Index (+ Shipping + Taxes sub-tabs), Admin\\Themes\\Index (+ Editor), Admin\\Pages\\Index + Form, Admin\\Navigation\\Index, Admin\\Analytics\\Index, Admin\\Search\\Settings, Admin\\Apps\\Index + Show, Admin\\Developers\\Index. Toast notifications via Livewire events. Tests DashboardTest, ProductManagementTest, OrderManagementTest, DiscountManagementTest, SettingsTest.

Phase 10: Apps and Webhooks

Migrations: apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries. WebhookService (dispatch/sign/verify). DeliverWebhook job (retry backoff 60/300/1800/7200/43200s, HMAC-SHA256, circuit breaker after 5 failures). Tests WebhookDeliveryTest, WebhookSignatureTest.

Phase 1: Foundation

Install Sanctum; set up auth guards (web/customer/sanctum); create core migrations (organizations, stores, store_domains, store_users, store_settings, modify users); create Organization/Store/StoreDomain/StoreUser/StoreSettings models + factories + seeders; add core enums (StoreStatus, StoreUserRole, StoreDomainType); create ResolveStore middleware (resolves storefront by Host header, admin by session); register storefront + admin middleware groups in bootstrap/app.php; create App\Models\Concerns\BelongsToStore trait + App\Models\Scopes\StoreScope; scaffold admin and customer auth (Livewire login/logout, customer guard, CustomerUserProvider, rate limiting); create authorization policies (Product/Order/Collection/Discount/Customer/Store/Page/Theme/Fulfillment/Refund) with role helper; admin routes under /admin/*; storefront routes on storefront host; 404 for unknown host, 503 for suspended store. Run migrations and seed. Write Pest tests for Tenancy (TenantResolutionTest, StoreIsolationTest) + Auth (AdminAuthTest, CustomerAuthTest, SanctumTokenTest). All tests must pass.

Phase 11: Polish

Accessibility audit (skip links, ARIA labels, focus mgmt in modals). Responsive breakpoints (sm/md/lg/xl). Dark mode (dark: prefix). Error pages 404/503 themed. Structured JSON logging channel. Seed data (demo store: storefront + admin + customers + products + orders).

Phase 2: Catalog

Build product catalog: migrations for products/product_options/product_option_values/product_variants/variant_option_values/inventory_items/collections/collection_products/product_media. Models with relationships + factories + seeders. Enums: ProductStatus, VariantStatus, CollectionStatus, MediaType, MediaStatus, InventoryPolicy. Services: ProductService (CRUD, status transitions), VariantMatrixService (rebuildMatrix), InventoryService (check/reserve/release/commit/restock), HandleGenerator (unique slugs per store), ProcessMediaUpload job. All services use DB transactions. Write unit tests HandleGeneratorTest, feature tests ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest. All pass.

Phase 12: Full Test Suite + Playwright Review

Run php artisan test, fix any failures. Run vendor/bin/pint. Fresh migrate --seed. Write + run Pest browser tests under tests/Browser/ as described in specs/08-PLAYWRIGHT-E2E-PLAN.md for full storefront + admin smoke. Playwright MCP: live review on http://shop.test demonstrating every feature (customer side: home, product, cart, checkout, order confirmation, account login/orders; admin side: login, dashboard, products CRUD, orders, discounts, settings). Fix any bugs encountered; restart the review until no bugs remain.

Phase 3: Themes and Storefront Layout

Migrations: themes, theme_files, theme_settings, pages, navigation_menus, navigation_items. Models + factories + seeders. Enums: ThemeStatus, PageStatus, NavigationItemType. Build storefront Blade layout (resources/views/storefront/): layouts/app.blade.php with header/announcement/main/footer/cart drawer (dark-mode ready). Pages: home, collection, product, cart, search, page. Account pages: login, register, dashboard, orders index/show, addresses. Error pages: 404, 503. Storefront components: product-card, price, badge, quantity-selector, address-form, order-summary, breadcrumbs, pagination. Storefront Livewire components: Home, Collections\\Index, Collections\\Show, Products\\Show, Cart\\Show, CartDrawer, Search\\Index, Search\\Modal, Pages\\Show. NavigationService with buildTree and resolveUrl. ThemeSettings singleton. Tests to confirm pages render.

Phase 8: Search

Migrations: search_settings, search_queries, FTS5 virtual products_fts table. SearchService (search/autocomplete/syncProduct/removeProduct). ProductObserver hooking create/update/delete. Storefront Search\\Modal + Search\\Index UI. Tests SearchTest, AutocompleteTest.

Phase 4: Cart, Checkout, Discounts, Shipping, Taxes

Migrations: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Models + factories + seeders. Enums: CartStatus, CheckoutStatus, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode. Services: CartService (create/addLine/updateLine/removeLine, session binding, mergeOnLogin, version increments), DiscountService (validate/calculate), ShippingCalculator (getAvailableRates/calculate), TaxCalculator (calculate/extractInclusive/addExclusive), PricingEngine (full pipeline), CheckoutService state machine, ExpireAbandonedCheckouts + CleanupAbandonedCarts jobs. ValueObjects PricingResult + TaxLine. Storefront UI: CartDrawer, Cart\\Show, Checkout\\Show (stepper), Checkout\\Confirmation. Unit tests PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest. Feature tests CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest. All pass.

Phase 5: Payments, Orders, Fulfillment

Migrations: customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines. Models + factories + seeders. Enums: OrderStatus, FinancialStatus, FulfillmentStatus, PaymentMethod, PaymentStatus, RefundStatus, FulfillmentShipmentStatus. Contract PaymentProvider + MockPaymentProvider (magic card numbers for credit_card, PayPal instant, bank_transfer pending). OrderService (createFromCheckout with snapshots, generateOrderNumber sequential per store, cancel). RefundService. FulfillmentService (guard: only if paid or partially_refunded). CancelUnpaidBankTransferOrders job. Events: OrderCreated/OrderPaid/OrderFulfilled/OrderCancelled/OrderRefunded. Tests MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest, OrderCreationTest, RefundTest, FulfillmentTest.

Phase 9: Analytics

Migrations: analytics_events, analytics_daily. AnalyticsService (track/getDailyMetrics). AggregateAnalytics daily job. Tests EventIngestionTest, AggregationTest.


Continuing Phase 1. Now updating User model, adding customers table/model, customer guard, auth config, Livewire login.


<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use App\Enums\UserStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

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

    protected $fillable = [
        'name',
        'email',
        'password',
        'status',
    ];

    protected $hidden = [
        'password',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'last_login_at' => 'datetime',
            'password' => 'hashed',
            'status' => UserStatus::class,
        ];
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role', 'created_at');
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->whereKey($store->id)->first()?->pivot;

        if (! $pivot) {
            return null;
        }

        return $pivot->role instanceof StoreUserRole
            ? $pivot->role
            : StoreUserRole::tryFrom($pivot->role);
    }

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

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


The file /Users/fabianwesner/Herd/shop/app/Models/User.php has been updated successfully.

<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use App\Enums\UserStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

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

    protected $fillable = [
        'name',
        'email',
        'password',
        'status',
    ];

    protected $hidden = [
        'password',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'last_login_at' => 'datetime',
            'password' => 'hashed',
            'status' => UserStatus::class,
        ];
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')
            ->using(StoreUser::class)
            ->withPivot('role', 'created_at');
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->whereKey($store->id)->first()?->pivot;

        if (! $pivot) {
            return null;
        }

        return $pivot->role instanceof StoreUserRole
            ? $pivot->role
            : StoreUserRole::tryFrom($pivot->role);
    }

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

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

-    use HasFactory, Notifiable, TwoFactorAuthenticatable;

+    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

-     * The attributes that are mass assignable.

-     * The attributes that should be hidden for serialization.

-     * Get the attributes that should be cast.

+            'last_login_at' => 'datetime',

+            'status' => UserStatus::class,

+    public function stores(): BelongsToMany

+        return $this->belongsToMany(Store::class, 'store_users')

+            ->withPivot('role', 'created_at');

+    public function roleForStore(Store $store): ?StoreUserRole

+        $pivot = $this->stores()->whereKey($store->id)->first()?->pivot;

+        return $pivot->role instanceof StoreUserRole

+            : StoreUserRole::tryFrom($pivot->role);

+        return $this->status === UserStatus::Active;

<?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('');
    }
}


<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('customers', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('password')->nullable();
            $table->string('first_name')->nullable();
            $table->string('last_name')->nullable();
            $table->string('phone')->nullable();
            $table->string('state')->default('active');
            $table->boolean('accepts_marketing')->default(false);
            $table->text('tags_json')->nullable();
            $table->text('metadata_json')->nullable();
            $table->string('remember_token', 100)->nullable();
            $table->timestamp('email_verified_at')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'email']);
            $table->index('store_id');
            $table->index('email');
        });

        Schema::create('customer_password_reset_tokens', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->unique(['store_id', 'email']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('customer_password_reset_tokens');
        Schema::dropIfExists('customers');
    }
};


File created successfully at: /Users/fabianwesner/Herd/shop/database/migrations/2026_04_18_000010_create_customers_table.php

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('customers', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('password')->nullable();
            $table->string('first_name')->nullable();
            $table->string('last_name')->nullable();
            $table->string('phone')->nullable();
            $table->string('state')->default('active');
            $table->boolean('accepts_marketing')->default(false);
            $table->text('tags_json')->nullable();
            $table->text('metadata_json')->nullable();
            $table->string('remember_token', 100)->nullable();
            $table->timestamp('email_verified_at')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'email']);
            $table->index('store_id');
            $table->index('email');
        });

        Schema::create('customer_password_reset_tokens', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->unique(['store_id', 'email']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('customer_password_reset_tokens');
        Schema::dropIfExists('customers');
    }
};


<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Contracts\Auth\CanResetPassword;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class Customer extends Authenticatable implements CanResetPassword
{
    /** @use HasFactory<\Database\Factories\CustomerFactory> */
    use BelongsToStore, HasFactory, Notifiable;

    protected $fillable = [
        'store_id',
        'email',
        'password',
        'first_name',
        'last_name',
        'phone',
        'state',
        'accepts_marketing',
        'tags_json',
        'metadata_json',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'accepts_marketing' => 'boolean',
            'tags_json' => 'array',
            'metadata_json' => 'array',
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    public function fullName(): string
    {
        return trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->email;
    }

    public function getEmailForPasswordReset(): string
    {
        return $this->email;
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Models/Customer.php

<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Contracts\Auth\CanResetPassword;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class Customer extends Authenticatable implements CanResetPassword
{
    /** @use HasFactory<\Database\Factories\CustomerFactory> */
    use BelongsToStore, HasFactory, Notifiable;

    protected $fillable = [
        'store_id',
        'email',
        'password',
        'first_name',
        'last_name',
        'phone',
        'state',
        'accepts_marketing',
        'tags_json',
        'metadata_json',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'accepts_marketing' => 'boolean',
            'tags_json' => 'array',
            'metadata_json' => 'array',
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function addresses(): HasMany
    {
        return $this->hasMany(CustomerAddress::class);
    }

    public function fullName(): string
    {
        return trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->email;
    }

    public function getEmailForPasswordReset(): string
    {
        return $this->email;
    }
}


<?php

namespace Database\Factories;

use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;

/**
 * @extends Factory<Customer>
 */
class CustomerFactory extends Factory
{
    protected $model = Customer::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => Hash::make('password'),
            'first_name' => $this->faker->firstName(),
            'last_name' => $this->faker->lastName(),
            'phone' => $this->faker->phoneNumber(),
            'state' => 'active',
            'accepts_marketing' => false,
            'email_verified_at' => now(),
        ];
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/database/factories/CustomerFactory.php

<?php

namespace Database\Factories;

use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;

/**
 * @extends Factory<Customer>
 */
class CustomerFactory extends Factory
{
    protected $model = Customer::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => Hash::make('password'),
            'first_name' => $this->faker->firstName(),
            'last_name' => $this->faker->lastName(),
            'phone' => $this->faker->phoneNumber(),
            'state' => 'active',
            'accepts_marketing' => false,
            'email_verified_at' => now(),
        ];
    }
}


<?php

namespace App\Auth;

use App\Models\Store;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;

class CustomerUserProvider extends EloquentUserProvider
{
    public function retrieveByCredentials(array $credentials): ?Authenticatable
    {
        $credentials = $this->scopedCredentials($credentials);

        return parent::retrieveByCredentials($credentials);
    }

    public function retrieveById($identifier): ?Authenticatable
    {
        $query = $this->createModel()->newQuery();

        if ($storeId = $this->currentStoreId()) {
            $query->where('store_id', $storeId);
        }

        return $query->find($identifier);
    }

    protected function scopedCredentials(array $credentials): array
    {
        if ($storeId = $this->currentStoreId()) {
            $credentials['store_id'] = $storeId;
        }

        return $credentials;
    }

    protected function currentStoreId(): ?int
    {
        if (! app()->bound('current_store')) {
            return null;
        }

        $store = app('current_store');

        return $store instanceof Store ? $store->id : null;
    }
}


File created successfully at: /Users/fabianwesner/Herd/shop/app/Auth/CustomerUserProvider.php

<?php

namespace App\Auth;

use App\Models\Store;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;

class CustomerUserProvider extends EloquentUserProvider
{
    public function retrieveByCredentials(array $credentials): ?Authenticatable
    {
        $credentials = $this->scopedCredentials($credentials);

        return parent::retrieveByCredentials($credentials);
    }

    public function retrieveById($identifier): ?Authenticatable
    {
        $query = $this->createModel()->newQuery();

        if ($storeId = $this->currentStoreId()) {
            $query->where('store_id', $storeId);
        }

        return $query->find($identifier);
    }

    protected function scopedCredentials(array $credentials): array
    {
        if ($storeId = $this->currentStoreId()) {
            $credentials['store_id'] = $storeId;
        }

        return $credentials;
    }

    protected function currentStoreId(): ?int
    {
        if (! app()->bound('current_store')) {
            return null;
        }

        $store = app('current_store');

        return $store instanceof Store ? $store->id : null;
    }
}


<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Models\Customer;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        Auth::provider('customer', function ($app, array $config): CustomerUserProvider {
            return new CustomerUserProvider($app['hash'], $config['model']);
        });

        RateLimiter::for('login', function (Request $request): Limit {
            return Limit::perMinute(5)->by($request->ip());
        });

        RateLimiter::for('customer-login', function (Request $request): Limit {
            return Limit::perMinute(5)->by($request->ip());
        });

        RateLimiter::for('storefront-api', function (Request $request): Limit {
            return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
        });

        RateLimiter::for('admin-api', function (Request $request): Limit {
            return Limit::perMinute(600)->by($request->user()?->id ?: $request->ip());
        });
    }
}


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


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),

];


Edit

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

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


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

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

<?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),

];

'driver' => 'session',
'provider' => 'users',
],
+        'customer' => [
+            'driver' => 'session',
+            'provider' => 'customers',
+        ],
],
/*


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

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

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