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

<?php

namespace App\Models;

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

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

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

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

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

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


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

<?php

namespace App\Providers;

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

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

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

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

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

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


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

<?php

use Illuminate\Support\Facades\Route;

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

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

require __DIR__.'/settings.php';


     1→<?php
     2→
     3→use Illuminate\Foundation\Inspiring;
     4→use Illuminate\Support\Facades\Artisan;
     5→
     6→Artisan::command('inspire', function () {
     7→    $this->comment(Inspiring::quote());
     8→})->purpose('Display an inspiring quote');
     9→

<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');


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

<?php

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

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

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

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

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


     1→<?php
     2→
     3→/*
     4→|--------------------------------------------------------------------------
     5→| Test Case
     6→|--------------------------------------------------------------------------
     7→|
     8→| The closure you provide to your test functions is always bound to a specific PHPUnit test
     9→| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
    10→| need to change it using the "pest()" function to bind a different classes or traits.
    11→|
    12→*/
    13→
    14→pest()->extend(Tests\TestCase::class)
    15→ // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    16→    ->in('Feature');
    17→
    18→/*
    19→|--------------------------------------------------------------------------
    20→| Expectations
    21→|--------------------------------------------------------------------------
    22→|
    23→| When you're writing tests, you often need to check that values meet certain conditions. The
    24→| "expect()" function gives you access to a set of "expectations" methods that you can use
    25→| to assert different things. Of course, you may extend the Expectation API at any time.
    26→|
    27→*/
    28→
    29→expect()->extend('toBeOne', function () {
    30→    return $this->toBe(1);
    31→});
    32→
    33→/*
    34→|--------------------------------------------------------------------------
    35→| Functions
    36→|--------------------------------------------------------------------------
    37→|
    38→| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
    39→| project that you don't want to repeat in every file. Here you can also expose helpers as
    40→| global functions to help you to reduce the number of lines of code in your test files.
    41→|
    42→*/
    43→
    44→function something()
    45→{
    46→    // ..
    47→}
    48→

<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
 // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


Now I have a clear picture of the current codebase. Let me start building Phase 1. This is massive, so I'll spawn agents to work on different parts in parallel. But first, I need to handle the foundation sequentially since everything depends on it.

Let me start by spawning an agent to implement Phase 1 - it's a big phase but needs to be done as a cohesive unit since all the pieces depend on each other.

Actually, given the complexity, I should spawn a dedicated agent for Phase 1 implementation. The agent will need comprehensive context about what to build. Let me spawn it now.

But wait - the Phase 1 agent needs to:

Looking at everything that needs to happen - config updates, migrations, models, enums, middleware, traits, auth components, policies, and tests - this is too much to split effectively. I'll spawn one comprehensive agent to handle Phase 1 end-to-end since all these pieces are interdependent.


Now I have the full picture. Let me start implementing Phase 1 (Foundation) with a dedicated agent. This is the critical path.


Task

Implement Phase 1 Foundation

Prompt:
You are implementing Phase 1 (Foundation) of a Laravel 12 e-commerce platform. This is the CRITICAL first phase - everything depends on it. Work in /Users/fabianwesner/Herd/shop.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4
- PHPStan max level compliance - explicit return types everywhere, fully typed properties, no mixed
- Use `casts()` method not `$casts` property on models
- Use PHP 8 constructor property promotion
- Run `vendor/bin/pint --dirty` after code changes
- Never use em-dash (—) in texts

## STEP 1.1: Config Updates

### config/database.php - Update SQLite connection:
```php
'sqlite' => [
    'driver' => 'sqlite',
    'url' => env('DB_URL'),
    'database' => env('DB_DATABASE', database_path('database.sqlite')),
    'prefix' => '',
    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
    'busy_timeout' => 5000,
    'journal_mode' => 'wal',
    'synchronous' => 'normal',
    'transaction_mode' => 'DEFERRED',
],
```

### config/auth.php - Add customer guard and provider:
Add to guards:
```php
'customer' => [
    'driver' => 'session',
    'provider' => 'customers',
],
```
Add to providers:
```php
'customers' => [
    'driver' => 'eloquent',
    'model' => App\Models\Customer::class,
],
```
Add to passwords:
```php
'customers' => [
    'provider' => 'customers',
    'table' => 'customer_password_reset_tokens',
    'expire' => 60,
    'throttle' => 60,
],
```

### bootstrap/app.php - Register middleware and API routes:
```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'store.resolve' => \App\Http\Middleware\ResolveStore::class,
            'role.check' => \App\Http\Middleware\CheckStoreRole::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();
```

## STEP 1.2: Core Migrations

Update the EXISTING users migration (0001_01_01_000000_create_users_table.php) to add:
- `status` TEXT NOT NULL DEFAULT 'active' (check: active, disabled)
- `last_login_at` TIMESTAMP nullable
- Rename `password` column to `password_hash`

Create NEW migrations in proper order:

1. `create_organizations_table`:
   - id, name (string), billing_email (string), timestamps
   - Index on billing_email

2. `create_stores_table`:
   - id, organization_id (FK -> organizations CASCADE), name (string), handle (string unique), status (string default 'active' check: active/suspended), default_currency (string default 'USD'), default_locale (string default 'en'), timezone (string default 'UTC'), timestamps
   - Indexes: handle unique, organization_id, status

3. `create_store_domains_table`:
   - id, store_id (FK -> stores CASCADE), hostname (string unique), type (string default 'storefront' check: storefront/admin/api), is_primary (boolean default false), tls_mode (string default 'managed'), created_at timestamp
   - Indexes: hostname unique, store_id

4. `create_store_users_table`:
   - Composite PK: store_id + user_id (both FK with CASCADE)
   - role (string default 'staff' check: owner/admin/staff/support)
   - created_at timestamp
   - Indexes: user_id, role

5. `create_store_settings_table`:
   - store_id (PK, FK -> stores CASCADE)
   - settings_json (text default '{}')
   - updated_at timestamp

6. `create_customer_password_reset_tokens_table`:
   - Composite PK: store_id + email
   - store_id FK -> stores CASCADE
   - token, created_at

Also create the cache and jobs tables properly (the existing stubs are empty):
- Ensure cache table has: key (PK), value, expiration
- Ensure jobs table has: id, queue, payload, attempts, reserved_at, available_at, created_at
- Also job_batches and failed_jobs tables

## STEP 1.3: Enums (in app/Enums/)

Create these string-backed enums:
- `StoreStatus`: Active('active'), Suspended('suspended')
- `StoreUserRole`: Owner('owner'), Admin('admin'), Staff('staff'), Support('support')
- `StoreDomainType`: Storefront('storefront'), Admin('admin'), Api('api')
- `UserStatus`: Active('active'), Disabled('disabled')

## STEP 1.4: Core Models

### Organization (app/Models/Organization.php):
- $fillable: name, billing_email
- hasMany(Store::class)
- Factory with meaningful defaults

### Store (app/Models/Store.php):
- $fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts(): status -> StoreStatus
- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)
- Factory

### StoreDomain (app/Models/StoreDomain.php):
- $fillable: store_id, hostname, type, is_primary, tls_mode
- casts(): type -> StoreDomainType, is_primary -> boolean
- belongsTo(Store)
- Factory
- Note: no updated_at column, so set $timestamps appropriately or use CREATED_AT only

### StoreUser (app/Models/StoreUser.php):
- Extends Illuminate\Database\Eloquent\Relations\Pivot
- $table = 'store_users'
- casts(): role -> StoreUserRole
- No incrementing, no updated_at

### StoreSettings (app/Models/StoreSettings.php):
- $primaryKey = 'store_id', $incrementing = false
- $fillable: store_id, settings_json
- casts(): settings_json -> array
- belongsTo(Store)
- No created_at (only updated_at)
- Factory

### User (update existing app/Models/User.php):
- Keep existing functionality
- Add: status (with UserStatus cast), last_login_at (datetime cast)
- Add `password_hash` to fillable (remove `password`)
- Override getAuthPassword() to return $this->password_hash
- Add belongsToMany(Store::class) through store_users
- Add `roleForStore(Store $store): ?StoreUserRole` method
- Add password_hash to hidden array
- Update casts to use password_hash => 'hashed'

## STEP 1.5: BelongsToStore Trait and StoreScope

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

### app/Models/Concerns/BelongsToStore.php trait:
- Boot method: apply StoreScope and auto-set store_id on creating event
- belongsTo(Store) relationship

## STEP 1.6: ResolveStore Middleware

### app/Http/Middleware/ResolveStore.php:
- For storefront routes: resolve by hostname from store_domains, cache 5 minutes
- For admin routes: resolve from session current_store_id, verify user has store_users record
- Abort 404 if not found, 503 if suspended
- Bind Store as 'current_store' singleton

### app/Http/Middleware/CheckStoreRole.php:
- Takes comma-separated roles as parameter
- Verifies authenticated user has one of the specified roles for current store
- Abort 403 if not

### app/Http/Middleware/CustomerAuthenticate.php:
- Check Auth::guard('customer')->check()
- Redirect to /account/login if not authenticated

## STEP 1.7: Auth Components

### app/Providers/CustomerUserProvider.php:
Custom Eloquent user provider that injects store_id into queries.

### Register rate limiters in AppServiceProvider::boot():
- 'login': 5 per minute per IP
- 'api.admin': 60 per minute per token
- 'api.storefront': 120 per minute per IP
- 'checkout': 10 per minute per session
- 'search': 30 per minute per IP
- 'analytics': 60 per minute per IP

### Admin Auth Livewire Component (app/Livewire/Admin/Auth/Login.php):
- Properties: email, password, remember
- Login action: validate, attempt with web guard, redirect to /admin
- Rate limited, regenerate session

### Customer Auth Livewire Components:
- app/Livewire/Storefront/Account/Auth/Login.php
- app/Livewire/Storefront/Account/Auth/Register.php

### Views:
- resources/views/livewire/admin/auth/login.blade.php (simple Flux UI form)
- resources/views/livewire/storefront/account/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/register.blade.php

## STEP 1.8: Authorization Policies

Create all policies in app/Policies/ using a ChecksStoreRole trait (app/Traits/ChecksStoreRole.php):
- ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy

Each follows the permission matrix:
- Owner: full access
- Admin: full access except delete store
- Staff: CRUD on products/collections/discounts/pages, manage orders/fulfillments
- Support: read-only on products/orders/customers

Register gates in AppServiceProvider: manage-store-settings, manage-staff, manage-developers, view-analytics, manage-shipping, manage-taxes, manage-search-settings, manage-navigation, manage-apps

## ROUTES

Update routes/web.php for admin and storefront routes.
Create routes/api.php with basic structure (can be filled in later phases).

## TESTS

Write Pest tests for all Phase 1 functionality:
- tests/Feature/Tenancy/TenantResolutionTest.php
- tests/Feature/Tenancy/StoreIsolationTest.php
- tests/Feature/Auth/AdminAuthTest.php
- tests/Feature/Auth/CustomerAuthTest.php

Each test file should have the tests specified in the roadmap. Use RefreshDatabase trait. Create helper functions in tests/Pest.php:
- createStoreContext(): creates Organization, Store, StoreDomain, User with Owner role, binds current_store
- actingAsAdmin(User): authenticates and sets store session

## EXECUTION ORDER:
1. First update config files
2. Create enums
3. Update existing migration + create new migrations
4. Create models with relationships
5. Create traits/scopes
6. Create middleware
7. Create auth components (providers, Livewire, views)
8. Create policies + gates
9. Update routes
10. Write tests
11. Run migrations: php artisan migrate:fresh
12. Run tests: php artisan test
13. Run pint: vendor/bin/pint --dirty

DO NOT create documentation files. Focus on working code. Make sure all migrations run cleanly and all tests pass.


Spawned successfully.
agent_id: phase1-builder@wild-sleeping-shell
name: phase1-builder
team_name: wild-sleeping-shell
The agent is now running and will receive instructions via mailbox.

Prompt:
You are implementing Phase 1 (Foundation) of a Laravel 12 e-commerce platform. This is the CRITICAL first phase - everything depends on it. Work in /Users/fabianwesner/Herd/shop.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4
- PHPStan max level compliance - explicit return types everywhere, fully typed properties, no mixed
- Use `casts()` method not `$casts` property on models
- Use PHP 8 constructor property promotion
- Run `vendor/bin/pint --dirty` after code changes
- Never use em-dash (—) in texts

## STEP 1.1: Config Updates

### config/database.php - Update SQLite connection:
```php
'sqlite' => [
    'driver' => 'sqlite',
    'url' => env('DB_URL'),
    'database' => env('DB_DATABASE', database_path('database.sqlite')),
    'prefix' => '',
    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
    'busy_timeout' => 5000,
    'journal_mode' => 'wal',
    'synchronous' => 'normal',
    'transaction_mode' => 'DEFERRED',
],
```

### config/auth.php - Add customer guard and provider:
Add to guards:
```php
'customer' => [
    'driver' => 'session',
    'provider' => 'customers',
],
```
Add to providers:
```php
'customers' => [
    'driver' => 'eloquent',
    'model' => App\Models\Customer::class,
],
```
Add to passwords:
```php
'customers' => [
    'provider' => 'customers',
    'table' => 'customer_password_reset_tokens',
    'expire' => 60,
    'throttle' => 60,
],
```

### bootstrap/app.php - Register middleware and API routes:
```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'store.resolve' => \App\Http\Middleware\ResolveStore::class,
            'role.check' => \App\Http\Middleware\CheckStoreRole::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();
```

## STEP 1.2: Core Migrations

Update the EXISTING users migration (0001_01_01_000000_create_users_table.php) to add:
- `status` TEXT NOT NULL DEFAULT 'active' (check: active, disabled)
- `last_login_at` TIMESTAMP nullable
- Rename `password` column to `password_hash`

Create NEW migrations in proper order:

1. `create_organizations_table`:
   - id, name (string), billing_email (string), timestamps
   - Index on billing_email

2. `create_stores_table`:
   - id, organization_id (FK -> organizations CASCADE), name (string), handle (string unique), status (string default 'active' check: active/suspended), default_currency (string default 'USD'), default_locale (string default 'en'), timezone (string default 'UTC'), timestamps
   - Indexes: handle unique, organization_id, status

3. `create_store_domains_table`:
   - id, store_id (FK -> stores CASCADE), hostname (string unique), type (string default 'storefront' check: storefront/admin/api), is_primary (boolean default false), tls_mode (string default 'managed'), created_at timestamp
   - Indexes: hostname unique, store_id

4. `create_store_users_table`:
   - Composite PK: store_id + user_id (both FK with CASCADE)
   - role (string default 'staff' check: owner/admin/staff/support)
   - created_at timestamp
   - Indexes: user_id, role

5. `create_store_settings_table`:
   - store_id (PK, FK -> stores CASCADE)
   - settings_json (text default '{}')
   - updated_at timestamp

6. `create_customer_password_reset_tokens_table`:
   - Composite PK: store_id + email
   - store_id FK -> stores CASCADE
   - token, created_at

Also create the cache and jobs tables properly (the existing stubs are empty):
- Ensure cache table has: key (PK), value, expiration
- Ensure jobs table has: id, queue, payload, attempts, reserved_at, available_at, created_at
- Also job_batches and failed_jobs tables

## STEP 1.3: Enums (in app/Enums/)

Create these string-backed enums:
- `StoreStatus`: Active('active'), Suspended('suspended')
- `StoreUserRole`: Owner('owner'), Admin('admin'), Staff('staff'), Support('support')
- `StoreDomainType`: Storefront('storefront'), Admin('admin'), Api('api')
- `UserStatus`: Active('active'), Disabled('disabled')

## STEP 1.4: Core Models

### Organization (app/Models/Organization.php):
- $fillable: name, billing_email
- hasMany(Store::class)
- Factory with meaningful defaults

### Store (app/Models/Store.php):
- $fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts(): status -> StoreStatus
- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)
- Factory

### StoreDomain (app/Models/StoreDomain.php):
- $fillable: store_id, hostname, type, is_primary, tls_mode
- casts(): type -> StoreDomainType, is_primary -> boolean
- belongsTo(Store)
- Factory
- Note: no updated_at column, so set $timestamps appropriately or use CREATED_AT only

### StoreUser (app/Models/StoreUser.php):
- Extends Illuminate\Database\Eloquent\Relations\Pivot
- $table = 'store_users'
- casts(): role -> StoreUserRole
- No incrementing, no updated_at

### StoreSettings (app/Models/StoreSettings.php):
- $primaryKey = 'store_id', $incrementing = false
- $fillable: store_id, settings_json
- casts(): settings_json -> array
- belongsTo(Store)
- No created_at (only updated_at)
- Factory

### User (update existing app/Models/User.php):
- Keep existing functionality
- Add: status (with UserStatus cast), last_login_at (datetime cast)
- Add `password_hash` to fillable (remove `password`)
- Override getAuthPassword() to return $this->password_hash
- Add belongsToMany(Store::class) through store_users
- Add `roleForStore(Store $store): ?StoreUserRole` method
- Add password_hash to hidden array
- Update casts to use password_hash => 'hashed'

## STEP 1.5: BelongsToStore Trait and StoreScope

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

### app/Models/Concerns/BelongsToStore.php trait:
- Boot method: apply StoreScope and auto-set store_id on creating event
- belongsTo(Store) relationship

## STEP 1.6: ResolveStore Middleware

### app/Http/Middleware/ResolveStore.php:
- For storefront routes: resolve by hostname from store_domains, cache 5 minutes
- For admin routes: resolve from session current_store_id, verify user has store_users record
- Abort 404 if not found, 503 if suspended
- Bind Store as 'current_store' singleton

### app/Http/Middleware/CheckStoreRole.php:
- Takes comma-separated roles as parameter
- Verifies authenticated user has one of the specified roles for current store
- Abort 403 if not

### app/Http/Middleware/CustomerAuthenticate.php:
- Check Auth::guard('customer')->check()
- Redirect to /account/login if not authenticated

## STEP 1.7: Auth Components

### app/Providers/CustomerUserProvider.php:
Custom Eloquent user provider that injects store_id into queries.

### Register rate limiters in AppServiceProvider::boot():
- 'login': 5 per minute per IP
- 'api.admin': 60 per minute per token
- 'api.storefront': 120 per minute per IP
- 'checkout': 10 per minute per session
- 'search': 30 per minute per IP
- 'analytics': 60 per minute per IP

### Admin Auth Livewire Component (app/Livewire/Admin/Auth/Login.php):
- Properties: email, password, remember
- Login action: validate, attempt with web guard, redirect to /admin
- Rate limited, regenerate session

### Customer Auth Livewire Components:
- app/Livewire/Storefront/Account/Auth/Login.php
- app/Livewire/Storefront/Account/Auth/Register.php

### Views:
- resources/views/livewire/admin/auth/login.blade.php (simple Flux UI form)
- resources/views/livewire/storefront/account/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/register.blade.php

## STEP 1.8: Authorization Policies

Create all policies in app/Policies/ using a ChecksStoreRole trait (app/Traits/ChecksStoreRole.php):
- ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy

Each follows the permission matrix:
- Owner: full access
- Admin: full access except delete store
- Staff: CRUD on products/collections/discounts/pages, manage orders/fulfillments
- Support: read-only on products/orders/customers

Register gates in AppServiceProvider: manage-store-settings, manage-staff, manage-developers, view-analytics, manage-shipping, manage-taxes, manage-search-settings, manage-navigation, manage-apps

## ROUTES

Update routes/web.php for admin and storefront routes.
Create routes/api.php with basic structure (can be filled in later phases).

## TESTS

Write Pest tests for all Phase 1 functionality:
- tests/Feature/Tenancy/TenantResolutionTest.php
- tests/Feature/Tenancy/StoreIsolationTest.php
- tests/Feature/Auth/AdminAuthTest.php
- tests/Feature/Auth/CustomerAuthTest.php

Each test file should have the tests specified in the roadmap. Use RefreshDatabase trait. Create helper functions in tests/Pest.php:
- createStoreContext(): creates Organization, Store, StoreDomain, User with Owner role, binds current_store
- actingAsAdmin(User): authenticates and sets store session

## EXECUTION ORDER:
1. First update config files
2. Create enums
3. Update existing migration + create new migrations
4. Create models with relationships
5. Create traits/scopes
6. Create middleware
7. Create auth components (providers, Livewire, views)
8. Create policies + gates
9. Update routes
10. Write tests
11. Run migrations: php artisan migrate:fresh
12. Run tests: php artisan test
13. Run pint: vendor/bin/pint --dirty

DO NOT create documentation files. Focus on working code. Make sure all migrations run cleanly and all tests pass.


Phase 1 agent is building the foundation. Let me check on its progress after a moment.


<tool_use_error>No task found with ID: phase1-builder@wild-sleeping-shell</tool_use_error>

Error: No task found with ID: phase1-builder@wild-sleeping-shell


Bash

Check team directory


config.json
inboxes


Let me wait for the phase1-builder agent to complete its work. Let me check its status.


     1→{
     2→  "name": "wild-sleeping-shell",
     3→  "description": "Building the complete e-commerce shop system from specs",
     4→  "createdAt": 1771003274925,
     5→  "leadAgentId": "team-lead@wild-sleeping-shell",
     6→  "leadSessionId": "0e05e308-1996-4bcf-a557-98f9225188b2",
     7→  "members": [
     8→    {
     9→      "agentId": "team-lead@wild-sleeping-shell",
    10→      "name": "team-lead",
    11→      "agentType": "team-lead",
    12→      "model": "claude-opus-4-6",
    13→      "joinedAt": 1771003274925,
    14→      "tmuxPaneId": "",
    15→      "cwd": "/Users/fabianwesner/Herd/shop",
    16→      "subscriptions": []
    17→    },
    18→    {
    19→      "agentId": "phase1-builder@wild-sleeping-shell",
    20→      "name": "phase1-builder",
    21→      "agentType": "general-purpose",
    22→      "model": "claude-opus-4-6",
    23→      "prompt": "You are implementing Phase 1 (Foundation) of a Laravel 12 e-commerce platform. This is the CRITICAL first phase - everything depends on it. Work in /Users/fabianwesner/Herd/shop.\n\nIMPORTANT RULES:\n- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4\n- PHPStan max level compliance - explicit return types everywhere, fully typed properties, no mixed\n- Use `casts()` method not `$casts` property on models\n- Use PHP 8 constructor property promotion\n- Run `vendor/bin/pint --dirty` after code changes\n- Never use em-dash (—) in texts\n\n## STEP 1.1: Config Updates\n\n### config/database.php - Update SQLite connection:\n```php\n'sqlite' => [\n    'driver' => 'sqlite',\n    'url' => env('DB_URL'),\n    'database' => env('DB_DATABASE', database_path('database.sqlite')),\n    'prefix' => '',\n    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),\n    'busy_timeout' => 5000,\n    'journal_mode' => 'wal',\n    'synchronous' => 'normal',\n    'transaction_mode' => 'DEFERRED',\n],\n```\n\n### config/auth.php - Add customer guard and provider:\nAdd to guards:\n```php\n'customer' => [\n    'driver' => 'session',\n    'provider' => 'customers',\n],\n```\nAdd to providers:\n```php\n'customers' => [\n    'driver' => 'eloquent',\n    'model' => App\\Models\\Customer::class,\n],\n```\nAdd to passwords:\n```php\n'customers' => [\n    'provider' => 'customers',\n    'table' => 'customer_password_reset_tokens',\n    'expire' => 60,\n    'throttle' => 60,\n],\n```\n\n### bootstrap/app.php - Register middleware and API routes:\n```php\nreturn Application::configure(basePath: dirname(__DIR__))\n    ->withRouting(\n        web: __DIR__.'/../routes/web.php',\n        api: __DIR__.'/../routes/api.php',\n        commands: __DIR__.'/../routes/console.php',\n        health: '/up',\n    )\n    ->withMiddleware(function (Middleware $middleware): void {\n        $middleware->alias([\n            'store.resolve' => \\App\\Http\\Middleware\\ResolveStore::class,\n            'role.check' => \\App\\Http\\Middleware\\CheckStoreRole::class,\n        ]);\n    })\n    ->withExceptions(function (Exceptions $exceptions): void {\n        //\n    })->create();\n```\n\n## STEP 1.2: Core Migrations\n\nUpdate the EXISTING users migration (0001_01_01_000000_create_users_table.php) to add:\n- `status` TEXT NOT NULL DEFAULT 'active' (check: active, disabled)\n- `last_login_at` TIMESTAMP nullable\n- Rename `password` column to `password_hash`\n\nCreate NEW migrations in proper order:\n\n1. `create_organizations_table`:\n   - id, name (string), billing_email (string), timestamps\n   - Index on billing_email\n\n2. `create_stores_table`:\n   - id, organization_id (FK -> organizations CASCADE), name (string), handle (string unique), status (string default 'active' check: active/suspended), default_currency (string default 'USD'), default_locale (string default 'en'), timezone (string default 'UTC'), timestamps\n   - Indexes: handle unique, organization_id, status\n\n3. `create_store_domains_table`:\n   - id, store_id (FK -> stores CASCADE), hostname (string unique), type (string default 'storefront' check: storefront/admin/api), is_primary (boolean default false), tls_mode (string default 'managed'), created_at timestamp\n   - Indexes: hostname unique, store_id\n\n4. `create_store_users_table`:\n   - Composite PK: store_id + user_id (both FK with CASCADE)\n   - role (string default 'staff' check: owner/admin/staff/support)\n   - created_at timestamp\n   - Indexes: user_id, role\n\n5. `create_store_settings_table`:\n   - store_id (PK, FK -> stores CASCADE)\n   - settings_json (text default '{}')\n   - updated_at timestamp\n\n6. `create_customer_password_reset_tokens_table`:\n   - Composite PK: store_id + email\n   - store_id FK -> stores CASCADE\n   - token, created_at\n\nAlso create the cache and jobs tables properly (the existing stubs are empty):\n- Ensure cache table has: key (PK), value, expiration\n- Ensure jobs table has: id, queue, payload, attempts, reserved_at, available_at, created_at\n- Also job_batches and failed_jobs tables\n\n## STEP 1.3: Enums (in app/Enums/)\n\nCreate these string-backed enums:\n- `StoreStatus`: Active('active'), Suspended('suspended')\n- `StoreUserRole`: Owner('owner'), Admin('admin'), Staff('staff'), Support('support')\n- `StoreDomainType`: Storefront('storefront'), Admin('admin'), Api('api')\n- `UserStatus`: Active('active'), Disabled('disabled')\n\n## STEP 1.4: Core Models\n\n### Organization (app/Models/Organization.php):\n- $fillable: name, billing_email\n- hasMany(Store::class)\n- Factory with meaningful defaults\n\n### Store (app/Models/Store.php):\n- $fillable: organization_id, name, handle, status, default_currency, default_locale, timezone\n- casts(): status -> StoreStatus\n- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)\n- Factory\n\n### StoreDomain (app/Models/StoreDomain.php):\n- $fillable: store_id, hostname, type, is_primary, tls_mode\n- casts(): type -> StoreDomainType, is_primary -> boolean\n- belongsTo(Store)\n- Factory\n- Note: no updated_at column, so set $timestamps appropriately or use CREATED_AT only\n\n### StoreUser (app/Models/StoreUser.php):\n- Extends Illuminate\\Database\\Eloquent\\Relations\\Pivot\n- $table = 'store_users'\n- casts(): role -> StoreUserRole\n- No incrementing, no updated_at\n\n### StoreSettings (app/Models/StoreSettings.php):\n- $primaryKey = 'store_id', $incrementing = false\n- $fillable: store_id, settings_json\n- casts(): settings_json -> array\n- belongsTo(Store)\n- No created_at (only updated_at)\n- Factory\n\n### User (update existing app/Models/User.php):\n- Keep existing functionality\n- Add: status (with UserStatus cast), last_login_at (datetime cast)\n- Add `password_hash` to fillable (remove `password`)\n- Override getAuthPassword() to return $this->password_hash\n- Add belongsToMany(Store::class) through store_users\n- Add `roleForStore(Store $store): ?StoreUserRole` method\n- Add password_hash to hidden array\n- Update casts to use password_hash => 'hashed'\n\n## STEP 1.5: BelongsToStore Trait and StoreScope\n\n### app/Models/Scopes/StoreScope.php:\n```php\nclass StoreScope implements Scope {\n    public function apply(Builder $builder, Model $model): void {\n        if (app()->bound('current_store')) {\n            $builder->where($model->getTable() . '.store_id', app('current_store')->id);\n        }\n    }\n}\n```\n\n### app/Models/Concerns/BelongsToStore.php trait:\n- Boot method: apply StoreScope and auto-set store_id on creating event\n- belongsTo(Store) relationship\n\n## STEP 1.6: ResolveStore Middleware\n\n### app/Http/Middleware/ResolveStore.php:\n- For storefront routes: resolve by hostname from store_domains, cache 5 minutes\n- For admin routes: resolve from session current_store_id, verify user has store_users record\n- Abort 404 if not found, 503 if suspended\n- Bind Store as 'current_store' singleton\n\n### app/Http/Middleware/CheckStoreRole.php:\n- Takes comma-separated roles as parameter\n- Verifies authenticated user has one of the specified roles for current store\n- Abort 403 if not\n\n### app/Http/Middleware/CustomerAuthenticate.php:\n- Check Auth::guard('customer')->check()\n- Redirect to /account/login if not authenticated\n\n## STEP 1.7: Auth Components\n\n### app/Providers/CustomerUserProvider.php:\nCustom Eloquent user provider that injects store_id into queries.\n\n### Register rate limiters in AppServiceProvider::boot():\n- 'login': 5 per minute per IP\n- 'api.admin': 60 per minute per token\n- 'api.storefront': 120 per minute per IP\n- 'checkout': 10 per minute per session\n- 'search': 30 per minute per IP\n- 'analytics': 60 per minute per IP\n\n### Admin Auth Livewire Component (app/Livewire/Admin/Auth/Login.php):\n- Properties: email, password, remember\n- Login action: validate, attempt with web guard, redirect to /admin\n- Rate limited, regenerate session\n\n### Customer Auth Livewire Components:\n- app/Livewire/Storefront/Account/Auth/Login.php\n- app/Livewire/Storefront/Account/Auth/Register.php\n\n### Views:\n- resources/views/livewire/admin/auth/login.blade.php (simple Flux UI form)\n- resources/views/livewire/storefront/account/auth/login.blade.php\n- resources/views/livewire/storefront/account/auth/register.blade.php\n\n## STEP 1.8: Authorization Policies\n\nCreate all policies in app/Policies/ using a ChecksStoreRole trait (app/Traits/ChecksStoreRole.php):\n- ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy\n\nEach follows the permission matrix:\n- Owner: full access\n- Admin: full access except delete store\n- Staff: CRUD on products/collections/discounts/pages, manage orders/fulfillments\n- Support: read-only on products/orders/customers\n\nRegister gates in AppServiceProvider: manage-store-settings, manage-staff, manage-developers, view-analytics, manage-shipping, manage-taxes, manage-search-settings, manage-navigation, manage-apps\n\n## ROUTES\n\nUpdate routes/web.php for admin and storefront routes.\nCreate routes/api.php with basic structure (can be filled in later phases).\n\n## TESTS\n\nWrite Pest tests for all Phase 1 functionality:\n- tests/Feature/Tenancy/TenantResolutionTest.php\n- tests/Feature/Tenancy/StoreIsolationTest.php\n- tests/Feature/Auth/AdminAuthTest.php\n- tests/Feature/Auth/CustomerAuthTest.php\n\nEach test file should have the tests specified in the roadmap. Use RefreshDatabase trait. Create helper functions in tests/Pest.php:\n- createStoreContext(): creates Organization, Store, StoreDomain, User with Owner role, binds current_store\n- actingAsAdmin(User): authenticates and sets store session\n\n## EXECUTION ORDER:\n1. First update config files\n2. Create enums\n3. Update existing migration + create new migrations\n4. Create models with relationships\n5. Create traits/scopes\n6. Create middleware\n7. Create auth components (providers, Livewire, views)\n8. Create policies + gates\n9. Update routes\n10. Write tests\n11. Run migrations: php artisan migrate:fresh\n12. Run tests: php artisan test\n13. Run pint: vendor/bin/pint --dirty\n\nDO NOT create documentation files. Focus on working code. Make sure all migrations run cleanly and all tests pass.",
    24→      "color": "blue",
    25→      "planModeRequired": false,
    26→      "joinedAt": 1771003390848,
    27→      "tmuxPaneId": "in-process",
    28→      "cwd": "/Users/fabianwesner/Herd/shop",
    29→      "subscriptions": [],
    30→      "backendType": "in-process"
    31→    }
    32→  ]
    33→}

wild-sleeping-shell

Prompt:
You are implementing Phase 1 (Foundation) of a Laravel 12 e-commerce platform. This is the CRITICAL first phase - everything depends on it. Work in /Users/fabianwesner/Herd/shop.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4
- PHPStan max level compliance - explicit return types everywhere, fully typed properties, no mixed
- Use `casts()` method not `$casts` property on models
- Use PHP 8 constructor property promotion
- Run `vendor/bin/pint --dirty` after code changes
- Never use em-dash (—) in texts

## STEP 1.1: Config Updates

### config/database.php - Update SQLite connection:
```php
'sqlite' => [
    'driver' => 'sqlite',
    'url' => env('DB_URL'),
    'database' => env('DB_DATABASE', database_path('database.sqlite')),
    'prefix' => '',
    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
    'busy_timeout' => 5000,
    'journal_mode' => 'wal',
    'synchronous' => 'normal',
    'transaction_mode' => 'DEFERRED',
],
```

### config/auth.php - Add customer guard and provider:
Add to guards:
```php
'customer' => [
    'driver' => 'session',
    'provider' => 'customers',
],
```
Add to providers:
```php
'customers' => [
    'driver' => 'eloquent',
    'model' => App\Models\Customer::class,
],
```
Add to passwords:
```php
'customers' => [
    'provider' => 'customers',
    'table' => 'customer_password_reset_tokens',
    'expire' => 60,
    'throttle' => 60,
],
```

### bootstrap/app.php - Register middleware and API routes:
```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'store.resolve' => \App\Http\Middleware\ResolveStore::class,
            'role.check' => \App\Http\Middleware\CheckStoreRole::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();
```

## STEP 1.2: Core Migrations

Update the EXISTING users migration (0001_01_01_000000_create_users_table.php) to add:
- `status` TEXT NOT NULL DEFAULT 'active' (check: active, disabled)
- `last_login_at` TIMESTAMP nullable
- Rename `password` column to `password_hash`

Create NEW migrations in proper order:

1. `create_organizations_table`:
   - id, name (string), billing_email (string), timestamps
   - Index on billing_email

2. `create_stores_table`:
   - id, organization_id (FK -> organizations CASCADE), name (string), handle (string unique), status (string default 'active' check: active/suspended), default_currency (string default 'USD'), default_locale (string default 'en'), timezone (string default 'UTC'), timestamps
   - Indexes: handle unique, organization_id, status

3. `create_store_domains_table`:
   - id, store_id (FK -> stores CASCADE), hostname (string unique), type (string default 'storefront' check: storefront/admin/api), is_primary (boolean default false), tls_mode (string default 'managed'), created_at timestamp
   - Indexes: hostname unique, store_id

4. `create_store_users_table`:
   - Composite PK: store_id + user_id (both FK with CASCADE)
   - role (string default 'staff' check: owner/admin/staff/support)
   - created_at timestamp
   - Indexes: user_id, role

5. `create_store_settings_table`:
   - store_id (PK, FK -> stores CASCADE)
   - settings_json (text default '{}')
   - updated_at timestamp

6. `create_customer_password_reset_tokens_table`:
   - Composite PK: store_id + email
   - store_id FK -> stores CASCADE
   - token, created_at

Also create the cache and jobs tables properly (the existing stubs are empty):
- Ensure cache table has: key (PK), value, expiration
- Ensure jobs table has: id, queue, payload, attempts, reserved_at, available_at, created_at
- Also job_batches and failed_jobs tables

## STEP 1.3: Enums (in app/Enums/)

Create these string-backed enums:
- `StoreStatus`: Active('active'), Suspended('suspended')
- `StoreUserRole`: Owner('owner'), Admin('admin'), Staff('staff'), Support('support')
- `StoreDomainType`: Storefront('storefront'), Admin('admin'), Api('api')
- `UserStatus`: Active('active'), Disabled('disabled')

## STEP 1.4: Core Models

### Organization (app/Models/Organization.php):
- $fillable: name, billing_email
- hasMany(Store::class)
- Factory with meaningful defaults

### Store (app/Models/Store.php):
- $fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts(): status -> StoreStatus
- belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User) through store_users with pivot role, hasOne(StoreSettings)
- Factory

### StoreDomain (app/Models/StoreDomain.php):
- $fillable: store_id, hostname, type, is_primary, tls_mode
- casts(): type -> StoreDomainType, is_primary -> boolean
- belongsTo(Store)
- Factory
- Note: no updated_at column, so set $timestamps appropriately or use CREATED_AT only

### StoreUser (app/Models/StoreUser.php):
- Extends Illuminate\Database\Eloquent\Relations\Pivot
- $table = 'store_users'
- casts(): role -> StoreUserRole
- No incrementing, no updated_at

### StoreSettings (app/Models/StoreSettings.php):
- $primaryKey = 'store_id', $incrementing = false
- $fillable: store_id, settings_json
- casts(): settings_json -> array
- belongsTo(Store)
- No created_at (only updated_at)
- Factory

### User (update existing app/Models/User.php):
- Keep existing functionality
- Add: status (with UserStatus cast), last_login_at (datetime cast)
- Add `password_hash` to fillable (remove `password`)
- Override getAuthPassword() to return $this->password_hash
- Add belongsToMany(Store::class) through store_users
- Add `roleForStore(Store $store): ?StoreUserRole` method
- Add password_hash to hidden array
- Update casts to use password_hash => 'hashed'

## STEP 1.5: BelongsToStore Trait and StoreScope

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

### app/Models/Concerns/BelongsToStore.php trait:
- Boot method: apply StoreScope and auto-set store_id on creating event
- belongsTo(Store) relationship

## STEP 1.6: ResolveStore Middleware

### app/Http/Middleware/ResolveStore.php:
- For storefront routes: resolve by hostname from store_domains, cache 5 minutes
- For admin routes: resolve from session current_store_id, verify user has store_users record
- Abort 404 if not found, 503 if suspended
- Bind Store as 'current_store' singleton

### app/Http/Middleware/CheckStoreRole.php:
- Takes comma-separated roles as parameter
- Verifies authenticated user has one of the specified roles for current store
- Abort 403 if not

### app/Http/Middleware/CustomerAuthenticate.php:
- Check Auth::guard('customer')->check()
- Redirect to /account/login if not authenticated

## STEP 1.7: Auth Components

### app/Providers/CustomerUserProvider.php:
Custom Eloquent user provider that injects store_id into queries.

### Register rate limiters in AppServiceProvider::boot():
- 'login': 5 per minute per IP
- 'api.admin': 60 per minute per token
- 'api.storefront': 120 per minute per IP
- 'checkout': 10 per minute per session
- 'search': 30 per minute per IP
- 'analytics': 60 per minute per IP

### Admin Auth Livewire Component (app/Livewire/Admin/Auth/Login.php):
- Properties: email, password, remember
- Login action: validate, attempt with web guard, redirect to /admin
- Rate limited, regenerate session

### Customer Auth Livewire Components:
- app/Livewire/Storefront/Account/Auth/Login.php
- app/Livewire/Storefront/Account/Auth/Register.php

### Views:
- resources/views/livewire/admin/auth/login.blade.php (simple Flux UI form)
- resources/views/livewire/storefront/account/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/register.blade.php

## STEP 1.8: Authorization Policies

Create all policies in app/Policies/ using a ChecksStoreRole trait (app/Traits/ChecksStoreRole.php):
- ProductPolicy, OrderPolicy, CollectionPolicy, DiscountPolicy, CustomerPolicy, StorePolicy, PagePolicy, ThemePolicy, FulfillmentPolicy, RefundPolicy

Each follows the permission matrix:
- Owner: full access
- Admin: full access except delete store
- Staff: CRUD on products/collections/discounts/pages, manage orders/fulfillments
- Support: read-only on products/orders/customers

Register gates in AppServiceProvider: manage-store-settings, manage-staff, manage-developers, view-analytics, manage-shipping, manage-taxes, manage-search-settings, manage-navigation, manage-apps

## ROUTES

Update routes/web.php for admin and storefront routes.
Create routes/api.php with basic structure (can be filled in later phases).

## TESTS

Write Pest tests for all Phase 1 functionality:
- tests/Feature/Tenancy/TenantResolutionTest.php
- tests/Feature/Tenancy/StoreIsolationTest.php
- tests/Feature/Auth/AdminAuthTest.php
- tests/Feature/Auth/CustomerAuthTest.php

Each test file should have the tests specified in the roadmap. Use RefreshDatabase trait. Create helper functions in tests/Pest.php:
- createStoreContext(): creates Organization, Store, StoreDomain, User with Owner role, binds current_store
- actingAsAdmin(User): authenticates and sets store session

## EXECUTION ORDER:
1. First update config files
2. Create enums
3. Update existing migration + create new migrations
4. Create models with relationships
5. Create traits/scopes
6. Create middleware
7. Create auth components (providers, Livewire, views)
8. Create policies + gates
9. Update routes
10. Write tests
11. Run migrations: php artisan migrate:fresh
12. Run tests: php artisan test
13. Run pint: vendor/bin/pint --dirty

DO NOT create documentation files. Focus on working code. Make sure all migrations run cleanly and all tests pass.


The phase1-builder agent is actively working. Let me wait for its completion by checking for messages.


Bash

sleep 30 && echo "Checking agent progress..."

Wait for agent progress


Checking agent progress...


No files found


The agent is still starting. Let me wait a bit more for it to create files.


Wait for agent to produce files


Checking again...


Good progress - enums and models are created. The agent is still working on middleware. Let me wait for it to finish.


sleep 120 && echo "Checking full progress..."

Wait for more progress


Checking full progress...


Excellent progress! Migrations, models, enums, middleware, and Livewire components are all created. Still waiting for policies and tests. Let me continue waiting.


sleep 120 && echo "Checking for policies and tests..."

Wait for policies and tests


Checking for policies and tests...


/Users/fabianwesner/Herd/shop/tests/Feature/Tenancy/TenantResolutionTest.php
/Users/fabianwesner/Herd/shop/tests/Feature/Tenancy/StoreIsolationTest.php


All Phase 1 files are created. Let me wait for the agent to finish running migrations and tests.


sleep 120 && echo "Checking if agent completed..."

Wait for agent completion


Checking if agent completed...


All Phase 1 tests and factories are created. The agent should be running migrations and tests now. Let me wait a bit more for it to complete.


Wait for final completion


Final check...


#6 [pending] Phase 6+7: Customer Accounts + Admin Panel [blocked by #5]
#7 [pending] Phase 8-10: Search, Analytics, Apps/Webhooks [blocked by #5]
#10 [completed] Implement Phase 1 Foundation (phase1-builder)
#1 [in_progress] Phase 1: Foundation - Config, Migrations, Models, Enums, Middleware, Auth (team-lead)
#2 [pending] Phase 2: Catalog - Products, Variants, Inventory, Collections, Media [blocked by #1]
#3 [pending] Phase 3: Themes, Pages, Navigation, Storefront Layout [blocked by #1]
#8 [pending] Phase 11-12: Polish + Full Test Suite + Review [blocked by #6, #7]
#4 [pending] Phase 4: Cart, Checkout, Discounts, Shipping, Taxes [blocked by #2, #3]
#5 [pending] Phase 5: Payments, Orders, Fulfillment [blocked by #4]

Implement Phase 1 Foundation

Phase 5: Payments, Orders, Fulfillment


Phase 1 agent has completed. Let me verify the state of the work by running migrations and tests.


php artisan migrate:fresh 2>&1 | tail -40

Run fresh migrations to verify Phase 1


  Dropping all tables ............................................ 5.66ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.82ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.44ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.37ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.92ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.35ms DONE
  2025_08_15_000001_create_organizations_table ................... 0.20ms DONE
  2025_08_15_000002_create_stores_table .......................... 0.53ms DONE
  2025_08_15_000003_create_store_domains_table ................... 0.31ms DONE
  2025_08_15_000004_create_store_users_table ..................... 0.31ms DONE
  2025_08_15_000005_create_store_settings_table .................. 0.16ms DONE
  2025_08_15_000006_create_customer_password_reset_tokens_table .. 0.19ms DONE


All migrations pass. Let me run the tests.


php artisan test --filter="Tenancy\|AdminAuth\|CustomerAuth" 2>&1 | tail -60

Run Phase 1 tests


INFO  No tests found.


php artisan test tests/Feature/Tenancy/ tests/Feature/Auth/AdminAuthTest.php tests/Feature/Auth/CustomerAuthTest.php 2>&1 | tail -80

Run Phase 1 specific tests


   PASS  Tests\Feature\Tenancy\StoreIsolationTest
  ✓ it creates models with store context automatically                   0.16s
  ✓ it scopes queries to current store                                   0.01s
  ✓ it isolates users by store through pivot                             0.01s
  ✓ it returns correct role for user in store                            0.01s
  ✓ it returns null role for user not in store                           0.01s
  ✓ it supports multiple roles across stores                             0.01s

   PASS  Tests\Feature\Tenancy\TenantResolutionTest
  ✓ it resolves store by hostname for storefront requests                0.03s
  ✓ it returns 404 when store hostname is not found                      0.01s
  ✓ it resolves store from session for admin routes                      0.03s
  ✓ it returns 404 when admin has no store in session                    0.01s
  ✓ it returns 503 when store is suspended for admin routes              0.01s
  ✓ it returns 404 when user does not belong to the store                0.01s

   PASS  Tests\Feature\Auth\AdminAuthTest
  ✓ it shows admin login page                                            0.02s
  ✓ it authenticates admin user with valid credentials                   0.04s
  ✓ it fails authentication with invalid credentials                     0.23s
  ✓ it validates email is required                                       0.03s
  ✓ it validates password is required                                    0.03s
  ✓ it rate limits login attempts                                        1.15s

   PASS  Tests\Feature\Auth\CustomerAuthTest
  ✓ it shows customer login page                                         0.02s
  ✓ it shows customer register page                                      0.01s
  ✓ it validates login email is required                                 0.03s
  ✓ it validates login password is required                              0.03s
  ✓ it validates registration fields are required                        0.06s
  ✓ it validates registration password confirmation                      0.06s

  Tests:    24 passed (58 assertions)
  Duration: 2.07s


All 24 Phase 1 tests pass. Let me now mark Phase 1 as complete and immediately launch Phase 2 and Phase 3 in parallel.


Updated task #1 status


Let me commit Phase 1 and then launch Phase 2 and Phase 3 in parallel.


git add app/Enums/ app/Models/ app/Http/Middleware/ app/Livewire/Admin/ app/Livewire/Storefront/ app/Policies/ app/Traits/ app/Providers/ database/migrations/ database/factories/ config/auth.php config/database.php bootstrap/app.php routes/ tests/Feature/Tenancy/ tests/Feature/Auth/AdminAuthTest.php tests/Feature/Auth/CustomerAuthTest.php tests/Pest.php resources/views/livewire/admin/ resources/views/livewire/storefront/ specs/progress.md

Stage Phase 1 files for commit


Check staged files


AM README.md
A  app/Enums/StoreDomainType.php
A  app/Enums/StoreStatus.php
A  app/Enums/StoreUserRole.php
A  app/Enums/UserStatus.php
A  app/Http/Middleware/CheckStoreRole.php
A  app/Http/Middleware/CustomerAuthenticate.php
A  app/Http/Middleware/ResolveStore.php
A  app/Livewire/Admin/Auth/Login.php
A  app/Livewire/Storefront/Account/Auth/Login.php
A  app/Livewire/Storefront/Account/Auth/Register.php
A  app/Models/Concerns/BelongsToStore.php
A  app/Models/Organization.php
A  app/Models/Scopes/StoreScope.php
A  app/Models/Store.php
A  app/Models/StoreDomain.php
A  app/Models/StoreSettings.php
A  app/Models/StoreUser.php
M  app/Models/User.php
A  app/Policies/CollectionPolicy.php
A  app/Policies/CustomerPolicy.php
A  app/Policies/DiscountPolicy.php
A  app/Policies/FulfillmentPolicy.php
A  app/Policies/OrderPolicy.php
A  app/Policies/PagePolicy.php
A  app/Policies/ProductPolicy.php
A  app/Policies/RefundPolicy.php
A  app/Policies/StorePolicy.php
A  app/Policies/ThemePolicy.php
M  app/Providers/AppServiceProvider.php
A  app/Providers/CustomerUserProvider.php
A  app/Traits/ChecksStoreRole.php
M  bootstrap/app.php
 M composer.json
 M composer.lock
M  config/auth.php
M  config/database.php
A  database/factories/OrganizationFactory.php
A  database/factories/StoreDomainFactory.php
A  database/factories/StoreFactory.php
A  database/factories/StoreSettingsFactory.php
M  database/factories/UserFactory.php
M  database/migrations/0001_01_01_000000_create_users_table.php
M  database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php
A  database/migrations/2025_08_15_000001_create_organizations_table.php
A  database/migrations/2025_08_15_000002_create_stores_table.php
A  database/migrations/2025_08_15_000003_create_store_domains_table.php
A  database/migrations/2025_08_15_000004_create_store_users_table.php
A  database/migrations/2025_08_15_000005_create_store_settings_table.php
A  database/migrations/2025_08_15_000006_create_customer_password_reset_tokens_table.php
A  resources/views/livewire/admin/auth/login.blade.php
A  resources/views/livewire/storefront/account/auth/login.blade.php
A  resources/views/livewire/storefront/account/auth/register.blade.php
A  routes/api.php
M  routes/web.php
A  specs/progress.md
A  tests/Feature/Auth/AdminAuthTest.php
A  tests/Feature/Auth/CustomerAuthTest.php
 M tests/Feature/Settings/PasswordUpdateTest.php
A  tests/Feature/Tenancy/StoreIsolationTest.php


git commit -m "$(cat <<'EOF'
Phase 1: Foundation - migrations, models, auth, middleware, policies

Implements the complete foundation layer:
- Core migrations: organizations, stores, store_domains, store_users, store_settings
- Updated users table with status, last_login_at, password_hash
- Enums: StoreStatus, StoreUserRole, StoreDomainType, UserStatus
- Models with relationships and factories: Organization, Store, StoreDomain, StoreUser, StoreSettings
- BelongsToStore trait with StoreScope for multi-tenant isolation
- ResolveStore middleware (hostname + session-based resolution)
- CheckStoreRole middleware for RBAC
- CustomerAuthenticate middleware
- Custom CustomerUserProvider for store-scoped customer auth
- Admin and Customer auth Livewire components with views
- 10 authorization policies following the permission matrix
- Rate limiters for login, API, checkout, search, analytics
- All 24 Phase 1 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"

Commit Phase 1 Foundation


[claude-code-team-2 44d3167] Phase 1: Foundation - migrations, models, auth, middleware, policies
 59 files changed, 2181 insertions(+), 77 deletions(-)
 create mode 100644 README.md
 create mode 100644 app/Enums/StoreDomainType.php
 create mode 100644 app/Enums/StoreStatus.php
 create mode 100644 app/Enums/StoreUserRole.php
 create mode 100644 app/Enums/UserStatus.php
 create mode 100644 app/Http/Middleware/CheckStoreRole.php
 create mode 100644 app/Http/Middleware/CustomerAuthenticate.php
 create mode 100644 app/Http/Middleware/ResolveStore.php
 create mode 100644 app/Livewire/Admin/Auth/Login.php
 create mode 100644 app/Livewire/Storefront/Account/Auth/Login.php
 create mode 100644 app/Livewire/Storefront/Account/Auth/Register.php
 create mode 100644 app/Models/Concerns/BelongsToStore.php
 create mode 100644 app/Models/Organization.php
 create mode 100644 app/Models/Scopes/StoreScope.php
 create mode 100644 app/Models/Store.php
 create mode 100644 app/Models/StoreDomain.php
 create mode 100644 app/Models/StoreSettings.php
 create mode 100644 app/Models/StoreUser.php
 create mode 100644 app/Policies/CollectionPolicy.php
 create mode 100644 app/Policies/CustomerPolicy.php
 create mode 100644 app/Policies/DiscountPolicy.php
 create mode 100644 app/Policies/FulfillmentPolicy.php
 create mode 100644 app/Policies/OrderPolicy.php
 create mode 100644 app/Policies/PagePolicy.php
 create mode 100644 app/Policies/ProductPolicy.php
 create mode 100644 app/Policies/RefundPolicy.php
 create mode 100644 app/Policies/StorePolicy.php
 create mode 100644 app/Policies/ThemePolicy.php
 create mode 100644 app/Providers/CustomerUserProvider.php
 create mode 100644 app/Traits/ChecksStoreRole.php
 create mode 100644 database/factories/OrganizationFactory.php
 create mode 100644 database/factories/StoreDomainFactory.php
 create mode 100644 database/factories/StoreFactory.php
 create mode 100644 database/factories/StoreSettingsFactory.php
 create mode 100644 database/migrations/2025_08_15_000001_create_organizations_table.php
 create mode 100644 database/migrations/2025_08_15_000002_create_stores_table.php
 create mode 100644 database/migrations/2025_08_15_000003_create_store_domains_table.php
 create mode 100644 database/migrations/2025_08_15_000004_create_store_users_table.php
 create mode 100644 database/migrations/2025_08_15_000005_create_store_settings_table.php
 create mode 100644 database/migrations/2025_08_15_000006_create_customer_password_reset_tokens_table.php
 create mode 100644 resources/views/livewire/admin/auth/login.blade.php
 create mode 100644 resources/views/livewire/storefront/account/auth/login.blade.php
 create mode 100644 resources/views/livewire/storefront/account/auth/register.blade.php
 create mode 100644 routes/api.php
 create mode 100644 specs/progress.md
 create mode 100644 tests/Feature/Auth/AdminAuthTest.php
 create mode 100644 tests/Feature/Auth/CustomerAuthTest.php
 create mode 100644 tests/Feature/Tenancy/StoreIsolationTest.php
 create mode 100644 tests/Feature/Tenancy/TenantResolutionTest.php


Phase 1 committed. Now let me launch Phase 2 (Catalog) and Phase 3 (Themes/Storefront) in parallel since both only depend on Phase 1.


Updated task #2 status


Updated task #3 status


Task

Implement Phase 2 Catalog

Prompt:
You are implementing Phase 2 (Catalog) of a Laravel 12 e-commerce platform. Work in /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete with models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User. The BelongsToStore trait and StoreScope are in app/Models/Concerns/ and app/Models/Scopes/.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / SQLite / Pest v4
- PHPStan max level: explicit return types everywhere, fully typed properties, no mixed
- Use `casts()` method not `$casts` property
- Use PHP 8 constructor property promotion
- Never use em-dash

## MIGRATIONS (create in database/migrations/ with dates like 2025_08_15_000010_...)

1. `create_products_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), status (string default 'draft' check: draft/active/archived), description_html (text nullable), vendor (string nullable), product_type (string nullable), tags (text default '[]'), published_at (timestamp nullable), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status, published_at, vendor, product_type

2. `create_product_options_table`: id, product_id (FK->products CASCADE), name (string), position (integer default 0). Indexes: product_id, unique(product_id, position)

3. `create_product_option_values_table`: id, product_option_id (FK->product_options CASCADE), value (string), position (integer default 0). Indexes: product_option_id, unique(product_option_id, position)

4. `create_product_variants_table`: id, product_id (FK->products CASCADE), sku (string nullable), barcode (string nullable), price_amount (integer default 0), compare_at_amount (integer nullable), currency (string default 'USD'), weight_g (integer nullable), requires_shipping (boolean default true), is_default (boolean default false), position (integer default 0), status (string default 'active' check: active/archived), timestamps. Indexes: product_id, sku, barcode, product_id+position, product_id+is_default

5. `create_variant_option_values_table`: Composite PK (variant_id, product_option_value_id). Both FK with CASCADE. Index on product_option_value_id.

6. `create_inventory_items_table`: id, store_id (FK->stores CASCADE), variant_id (FK->product_variants CASCADE, unique), quantity_on_hand (integer default 0), quantity_reserved (integer default 0), policy (string default 'deny' check: deny/continue). Indexes: unique variant_id, store_id

7. `create_collections_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), description_html (text nullable), type (string default 'manual' check: manual/automated), status (string default 'active' check: draft/active/archived), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status

8. `create_collection_products_table`: Composite PK (collection_id, product_id). Both FK with CASCADE. position (integer default 0). Indexes: product_id, position

9. `create_product_media_table`: id, product_id (FK->products CASCADE), type (string default 'image' check: image/video), storage_key (string), alt_text (string nullable), width (integer nullable), height (integer nullable), mime_type (string nullable), byte_size (integer nullable), position (integer default 0), status (string default 'processing' check: processing/ready/failed), created_at timestamp. Indexes: product_id, product_id+position, status

## ENUMS (in app/Enums/)

- ProductStatus: Draft('draft'), Active('active'), Archived('archived')
- VariantStatus: Active('active'), Archived('archived')
- CollectionStatus: Draft('draft'), Active('active'), Archived('archived')
- MediaType: Image('image'), Video('video')
- MediaStatus: Processing('processing'), Ready('ready'), Failed('failed')
- InventoryPolicy: Deny('deny'), Continue('continue')
- CollectionType: Manual('manual'), Automated('automated')

## MODELS (in app/Models/)

### Product:
- use BelongsToStore trait
- $fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at
- casts(): status->ProductStatus, tags->array, published_at->datetime
- Relationships: hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection) via collection_products, belongsTo(Store)
- Factory with meaningful defaults

### ProductOption:
- $fillable: product_id, name, position
- Relationships: belongsTo(Product), hasMany(ProductOptionValue)
- Factory

### ProductOptionValue:
- $fillable: product_option_id, value, position
- Relationships: belongsTo(ProductOption)
- Factory

### ProductVariant:
- $fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status
- casts(): status->VariantStatus, requires_shipping->boolean, is_default->boolean, price_amount->integer, compare_at_amount->integer, weight_g->integer
- Relationships: belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue) via variant_option_values
- Factory

### InventoryItem:
- use BelongsToStore trait
- $fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy
- casts(): policy->InventoryPolicy, quantity_on_hand->integer, quantity_reserved->integer
- Relationships: belongsTo(ProductVariant, 'variant_id')
- No timestamps
- Factory
- Computed property/method: available(): int (on_hand - reserved)

### Collection:
- use BelongsToStore trait
- $fillable: store_id, title, handle, description_html, type, status
- casts(): type->CollectionType, status->CollectionStatus
- Relationships: belongsToMany(Product) via collection_products with pivot position, belongsTo(Store)
- Factory

### ProductMedia:
- $fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status
- casts(): type->MediaType, status->MediaStatus
- Relationships: belongsTo(Product)
- Only created_at timestamp (const UPDATED_AT = null)
- Factory

## SERVICES

### app/Support/HandleGenerator.php:
- generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
- Slugify title, check uniqueness in store, append -1, -2 etc on collision

### app/Services/ProductService.php:
- create(Store $store, array $data): Product - creates product with nested variants/options
- update(Product $product, array $data): Product
- transitionStatus(Product $product, ProductStatus $newStatus): void - validates state machine
- delete(Product $product): void - only draft with no order references

### app/Services/VariantMatrixService.php:
- rebuildMatrix(Product $product): void - cartesian product, create missing, archive/delete orphaned

### app/Services/InventoryService.php:
- checkAvailability(InventoryItem $item, int $quantity): bool
- reserve(InventoryItem $item, int $quantity): void
- release(InventoryItem $item, int $quantity): void
- commit(InventoryItem $item, int $quantity): void
- restock(InventoryItem $item, int $quantity): void
- All in database transactions, throw InsufficientInventoryException when policy=deny and insufficient

### app/Exceptions/InsufficientInventoryException.php

### app/Exceptions/InvalidProductTransitionException.php

### app/Jobs/ProcessMediaUpload.php:
- Resizes images (thumbnail 150x150, medium 600x600, large 1200x1200)
- Updates ProductMedia status from processing to ready (or failed)
- For now just update status to ready since we don't need actual image processing

## TESTS

### tests/Feature/Products/ProductCrudTest.php (13 tests per spec):
- lists products, creates with default variant, generates unique handle, appends suffix on collision, updates product, transitions draft->active, rejects draft->active without priced variant, transitions active->archived, prevents active->draft with orders, hard deletes draft, prevents deletion with orders, filters by status, searches by title

### tests/Feature/Products/VariantTest.php (8 tests):
- creates from option matrix, preserves existing on add, archives orphaned with orders, deletes orphaned without orders, auto-creates default variant, validates SKU uniqueness, allows duplicate SKU across stores, allows null SKUs

### tests/Feature/Products/InventoryTest.php (8 tests):
- creates inventory with variant, checks availability, reserves, throws on insufficient+deny, allows overselling with continue, releases, commits, restocks

### tests/Feature/Products/CollectionTest.php (7 tests):
- creates with unique handle, adds products, removes products, reorders, transitions draft->active, lists with product count, scopes to store

### tests/Feature/Products/MediaUploadTest.php (6 tests):
- uploads image, processes uploaded image, rejects non-image, sets alt text, reorders positions, deletes media

### tests/Unit/HandleGeneratorTest.php (6 tests):
- generates slug, appends suffix on collision, increments suffix, handles special chars, excludes current record, scopes to store

Use createStoreContext() helper from tests/Pest.php. Use RefreshDatabase in feature tests.

After implementation:
1. Run: php artisan migrate:fresh
2. Run: php artisan test tests/Feature/Products/ tests/Unit/HandleGeneratorTest.php
3. Run: vendor/bin/pint --dirty
4. Fix any failing tests


Spawned successfully.
agent_id: phase2-catalog@wild-sleeping-shell
name: phase2-catalog
team_name: wild-sleeping-shell
The agent is now running and will receive instructions via mailbox.

Prompt:
You are implementing Phase 2 (Catalog) of a Laravel 12 e-commerce platform. Work in /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete with models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User. The BelongsToStore trait and StoreScope are in app/Models/Concerns/ and app/Models/Scopes/.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / SQLite / Pest v4
- PHPStan max level: explicit return types everywhere, fully typed properties, no mixed
- Use `casts()` method not `$casts` property
- Use PHP 8 constructor property promotion
- Never use em-dash

## MIGRATIONS (create in database/migrations/ with dates like 2025_08_15_000010_...)

1. `create_products_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), status (string default 'draft' check: draft/active/archived), description_html (text nullable), vendor (string nullable), product_type (string nullable), tags (text default '[]'), published_at (timestamp nullable), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status, published_at, vendor, product_type

2. `create_product_options_table`: id, product_id (FK->products CASCADE), name (string), position (integer default 0). Indexes: product_id, unique(product_id, position)

3. `create_product_option_values_table`: id, product_option_id (FK->product_options CASCADE), value (string), position (integer default 0). Indexes: product_option_id, unique(product_option_id, position)

4. `create_product_variants_table`: id, product_id (FK->products CASCADE), sku (string nullable), barcode (string nullable), price_amount (integer default 0), compare_at_amount (integer nullable), currency (string default 'USD'), weight_g (integer nullable), requires_shipping (boolean default true), is_default (boolean default false), position (integer default 0), status (string default 'active' check: active/archived), timestamps. Indexes: product_id, sku, barcode, product_id+position, product_id+is_default

5. `create_variant_option_values_table`: Composite PK (variant_id, product_option_value_id). Both FK with CASCADE. Index on product_option_value_id.

6. `create_inventory_items_table`: id, store_id (FK->stores CASCADE), variant_id (FK->product_variants CASCADE, unique), quantity_on_hand (integer default 0), quantity_reserved (integer default 0), policy (string default 'deny' check: deny/continue). Indexes: unique variant_id, store_id

7. `create_collections_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), description_html (text nullable), type (string default 'manual' check: manual/automated), status (string default 'active' check: draft/active/archived), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status

8. `create_collection_products_table`: Composite PK (collection_id, product_id). Both FK with CASCADE. position (integer default 0). Indexes: product_id, position

9. `create_product_media_table`: id, product_id (FK->products CASCADE), type (string default 'image' check: image/video), storage_key (string), alt_text (string nullable), width (integer nullable), height (integer nullable), mime_type (string nullable), byte_size (integer nullable), position (integer default 0), status (string default 'processing' check: processing/ready/failed), created_at timestamp. Indexes: product_id, product_id+position, status

## ENUMS (in app/Enums/)

- ProductStatus: Draft('draft'), Active('active'), Archived('archived')
- VariantStatus: Active('active'), Archived('archived')
- CollectionStatus: Draft('draft'), Active('active'), Archived('archived')
- MediaType: Image('image'), Video('video')
- MediaStatus: Processing('processing'), Ready('ready'), Failed('failed')
- InventoryPolicy: Deny('deny'), Continue('continue')
- CollectionType: Manual('manual'), Automated('automated')

## MODELS (in app/Models/)

### Product:
- use BelongsToStore trait
- $fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at
- casts(): status->ProductStatus, tags->array, published_at->datetime
- Relationships: hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection) via collection_products, belongsTo(Store)
- Factory with meaningful defaults

### ProductOption:
- $fillable: product_id, name, position
- Relationships: belongsTo(Product), hasMany(ProductOptionValue)
- Factory

### ProductOptionValue:
- $fillable: product_option_id, value, position
- Relationships: belongsTo(ProductOption)
- Factory

### ProductVariant:
- $fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status
- casts(): status->VariantStatus, requires_shipping->boolean, is_default->boolean, price_amount->integer, compare_at_amount->integer, weight_g->integer
- Relationships: belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue) via variant_option_values
- Factory

### InventoryItem:
- use BelongsToStore trait
- $fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy
- casts(): policy->InventoryPolicy, quantity_on_hand->integer, quantity_reserved->integer
- Relationships: belongsTo(ProductVariant, 'variant_id')
- No timestamps
- Factory
- Computed property/method: available(): int (on_hand - reserved)

### Collection:
- use BelongsToStore trait
- $fillable: store_id, title, handle, description_html, type, status
- casts(): type->CollectionType, status->CollectionStatus
- Relationships: belongsToMany(Product) via collection_products with pivot position, belongsTo(Store)
- Factory

### ProductMedia:
- $fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status
- casts(): type->MediaType, status->MediaStatus
- Relationships: belongsTo(Product)
- Only created_at timestamp (const UPDATED_AT = null)
- Factory

## SERVICES

### app/Support/HandleGenerator.php:
- generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
- Slugify title, check uniqueness in store, append -1, -2 etc on collision

### app/Services/ProductService.php:
- create(Store $store, array $data): Product - creates product with nested variants/options
- update(Product $product, array $data): Product
- transitionStatus(Product $product, ProductStatus $newStatus): void - validates state machine
- delete(Product $product): void - only draft with no order references

### app/Services/VariantMatrixService.php:
- rebuildMatrix(Product $product): void - cartesian product, create missing, archive/delete orphaned

### app/Services/InventoryService.php:
- checkAvailability(InventoryItem $item, int $quantity): bool
- reserve(InventoryItem $item, int $quantity): void
- release(InventoryItem $item, int $quantity): void
- commit(InventoryItem $item, int $quantity): void
- restock(InventoryItem $item, int $quantity): void
- All in database transactions, throw InsufficientInventoryException when policy=deny and insufficient

### app/Exceptions/InsufficientInventoryException.php

### app/Exceptions/InvalidProductTransitionException.php

### app/Jobs/ProcessMediaUpload.php:
- Resizes images (thumbnail 150x150, medium 600x600, large 1200x1200)
- Updates ProductMedia status from processing to ready (or failed)
- For now just update status to ready since we don't need actual image processing

## TESTS

### tests/Feature/Products/ProductCrudTest.php (13 tests per spec):
- lists products, creates with default variant, generates unique handle, appends suffix on collision, updates product, transitions draft->active, rejects draft->active without priced variant, transitions active->archived, prevents active->draft with orders, hard deletes draft, prevents deletion with orders, filters by status, searches by title

### tests/Feature/Products/VariantTest.php (8 tests):
- creates from option matrix, preserves existing on add, archives orphaned with orders, deletes orphaned without orders, auto-creates default variant, validates SKU uniqueness, allows duplicate SKU across stores, allows null SKUs

### tests/Feature/Products/InventoryTest.php (8 tests):
- creates inventory with variant, checks availability, reserves, throws on insufficient+deny, allows overselling with continue, releases, commits, restocks

### tests/Feature/Products/CollectionTest.php (7 tests):
- creates with unique handle, adds products, removes products, reorders, transitions draft->active, lists with product count, scopes to store

### tests/Feature/Products/MediaUploadTest.php (6 tests):
- uploads image, processes uploaded image, rejects non-image, sets alt text, reorders positions, deletes media

### tests/Unit/HandleGeneratorTest.php (6 tests):
- generates slug, appends suffix on collision, increments suffix, handles special chars, excludes current record, scopes to store

Use createStoreContext() helper from tests/Pest.php. Use RefreshDatabase in feature tests.

After implementation:
1. Run: php artisan migrate:fresh
2. Run: php artisan test tests/Feature/Products/ tests/Unit/HandleGeneratorTest.php
3. Run: vendor/bin/pint --dirty
4. Fix any failing tests


Implement Phase 3 Storefront

Prompt:
You are implementing Phase 3 (Themes, Pages, Navigation, Storefront Layout) of a Laravel 12 e-commerce platform. Work in /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4
- PHPStan max level: explicit return types, fully typed properties
- Use `casts()` method not `$casts` property
- Never use em-dash

## MIGRATIONS (create with dates 2025_08_15_000020_...)

1. `create_themes_table`: id, store_id (FK->stores CASCADE), name (string), version (string nullable), status (string default 'draft' check: draft/published), published_at (timestamp nullable), timestamps. Indexes: store_id, store_id+status

2. `create_theme_files_table`: id, theme_id (FK->themes CASCADE), path (string), storage_key (string), sha256 (string), byte_size (integer default 0). Indexes: unique(theme_id, path), theme_id

3. `create_theme_settings_table`: theme_id (PK, FK->themes CASCADE), settings_json (text default '{}'), updated_at timestamp

4. `create_pages_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), body_html (text nullable), status (string default 'draft' check: draft/published/archived), published_at (timestamp nullable), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status

5. `create_navigation_menus_table`: id, store_id (FK->stores CASCADE), handle (string), title (string), timestamps. Indexes: unique(store_id, handle), store_id

6. `create_navigation_items_table`: id, menu_id (FK->navigation_menus CASCADE), type (string default 'link' check: link/page/collection/product), label (string), url (string nullable), resource_id (integer nullable), position (integer default 0). Indexes: menu_id, menu_id+position

## ENUMS
- ThemeStatus: Draft('draft'), Published('published')
- PageStatus: Draft('draft'), Published('published'), Archived('archived')
- NavigationItemType: Link('link'), Page('page'), Collection('collection'), Product('product')

## MODELS

### Theme: use BelongsToStore, hasMany(ThemeFile), hasOne(ThemeSettings). Factory.
### ThemeFile: belongsTo(Theme). No timestamps. Factory.
### ThemeSettings: PK=theme_id, no incrementing, no created_at. belongsTo(Theme). casts settings_json->array. Factory.
### Page: use BelongsToStore, casts status->PageStatus, published_at->datetime. Factory.
### NavigationMenu: use BelongsToStore, hasMany(NavigationItem). Factory.
### NavigationItem: belongsTo(NavigationMenu, 'menu_id'). casts type->NavigationItemType. No timestamps. Factory.

## SERVICES

### app/Services/NavigationService.php:
- buildTree(NavigationMenu $menu): array - builds nav tree from menu items
- resolveUrl(NavigationItem $item): string - resolves URLs for different item types
- Cache per store with 5-minute TTL

## STOREFRONT LAYOUT

### resources/views/storefront/layouts/app.blade.php:
Full storefront layout with:
- Header with navigation, logo, search button, cart button, account link
- Main content area (@yield('content') or {{ $slot }})
- Footer with links
- Tailwind CSS v4 styling with dark mode support
- Responsive design

### STOREFRONT LIVEWIRE COMPONENTS

Create these Livewire components in app/Livewire/Storefront/:

1. **Home.php** - Home page with featured products/collections. Simple layout showing store name and featured content.
2. **Collections/Index.php** - Grid of all active collections. Paginated.
3. **Collections/Show.php** - Shows products in a collection. Accepts collection handle. Filter by price, sort (newest, price), pagination.
4. **Products/Show.php** - Product detail page. Accepts product handle. Shows images, title, description, price, variant selector, add-to-cart button.
5. **Cart/Show.php** - Full cart page (placeholder for now, will be implemented in Phase 4).
6. **Search/Index.php** - Search results page (placeholder for now, will be implemented in Phase 8).
7. **Pages/Show.php** - CMS page display. Accepts page handle. Renders body_html.

### VIEWS (resources/views/livewire/storefront/):
Create corresponding Blade views for each Livewire component. Use Tailwind CSS v4 with dark mode. Use Flux UI components where available (buttons, badges, inputs). Make them responsive.

### STOREFRONT ROUTES (update routes/web.php):
Add these storefront routes (with store.resolve middleware):
- GET / -> Storefront\Home
- GET /collections -> Storefront\Collections\Index
- GET /collections/{handle} -> Storefront\Collections\Show
- GET /products/{handle} -> Storefront\Products\Show
- GET /cart -> Storefront\Cart\Show
- GET /search -> Storefront\Search\Index
- GET /pages/{handle} -> Storefront\Pages\Show

## TESTS

### tests/Feature/Storefront/NavigationTest.php:
- builds navigation tree from menu items
- resolves URLs for page type items
- resolves URLs for collection type items
- resolves URLs for link type items

### tests/Feature/Storefront/PageTest.php:
- renders a published page
- returns 404 for draft page
- returns 404 for nonexistent page

After implementation:
1. Run: php artisan migrate:fresh
2. Run: php artisan test
3. Run: vendor/bin/pint --dirty
4. Fix any failing tests


Spawned successfully.
agent_id: phase3-storefront@wild-sleeping-shell
name: phase3-storefront
team_name: wild-sleeping-shell
The agent is now running and will receive instructions via mailbox.

Prompt:
You are implementing Phase 3 (Themes, Pages, Navigation, Storefront Layout) of a Laravel 12 e-commerce platform. Work in /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete.

IMPORTANT RULES:
- PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind CSS v4 / SQLite / Pest v4
- PHPStan max level: explicit return types, fully typed properties
- Use `casts()` method not `$casts` property
- Never use em-dash

## MIGRATIONS (create with dates 2025_08_15_000020_...)

1. `create_themes_table`: id, store_id (FK->stores CASCADE), name (string), version (string nullable), status (string default 'draft' check: draft/published), published_at (timestamp nullable), timestamps. Indexes: store_id, store_id+status

2. `create_theme_files_table`: id, theme_id (FK->themes CASCADE), path (string), storage_key (string), sha256 (string), byte_size (integer default 0). Indexes: unique(theme_id, path), theme_id

3. `create_theme_settings_table`: theme_id (PK, FK->themes CASCADE), settings_json (text default '{}'), updated_at timestamp

4. `create_pages_table`: id, store_id (FK->stores CASCADE), title (string), handle (string), body_html (text nullable), status (string default 'draft' check: draft/published/archived), published_at (timestamp nullable), timestamps. Indexes: unique(store_id, handle), store_id, store_id+status

5. `create_navigation_menus_table`: id, store_id (FK->stores CASCADE), handle (string), title (string), timestamps. Indexes: unique(store_id, handle), store_id

6. `create_navigation_items_table`: id, menu_id (FK->navigation_menus CASCADE), type (string default 'link' check: link/page/collection/product), label (string), url (string nullable), resource_id (integer nullable), position (integer default 0). Indexes: menu_id, menu_id+position

## ENUMS
- ThemeStatus: Draft('draft'), Published('published')
- PageStatus: Draft('draft'), Published('published'), Archived('archived')
- NavigationItemType: Link('link'), Page('page'), Collection('collection'), Product('product')

## MODELS

### Theme: use BelongsToStore, hasMany(ThemeFile), hasOne(ThemeSettings). Factory.
### ThemeFile: belongsTo(Theme). No timestamps. Factory.
### ThemeSettings: PK=theme_id, no incrementing, no created_at. belongsTo(Theme). casts settings_json->array. Factory.
### Page: use BelongsToStore, casts status->PageStatus, published_at->datetime. Factory.
### NavigationMenu: use BelongsToStore, hasMany(NavigationItem). Factory.
### NavigationItem: belongsTo(NavigationMenu, 'menu_id'). casts type->NavigationItemType. No timestamps. Factory.

## SERVICES

### app/Services/NavigationService.php:
- buildTree(NavigationMenu $menu): array - builds nav tree from menu items
- resolveUrl(NavigationItem $item): string - resolves URLs for different item types
- Cache per store with 5-minute TTL

## STOREFRONT LAYOUT

### resources/views/storefront/layouts/app.blade.php:
Full storefront layout with:
- Header with navigation, logo, search button, cart button, account link
- Main content area (@yield('content') or {{ $slot }})
- Footer with links
- Tailwind CSS v4 styling with dark mode support
- Responsive design

### STOREFRONT LIVEWIRE COMPONENTS

Create these Livewire components in app/Livewire/Storefront/:

1. **Home.php** - Home page with featured products/collections. Simple layout showing store name and featured content.
2. **Collections/Index.php** - Grid of all active collections. Paginated.
3. **Collections/Show.php** - Shows products in a collection. Accepts collection handle. Filter by price, sort (newest, price), pagination.
4. **Products/Show.php** - Product detail page. Accepts product handle. Shows images, title, description, price, variant selector, add-to-cart button.
5. **Cart/Show.php** - Full cart page (placeholder for now, will be implemented in Phase 4).
6. **Search/Index.php** - Search results page (placeholder for now, will be implemented in Phase 8).
7. **Pages/Show.php** - CMS page display. Accepts page handle. Renders body_html.

### VIEWS (resources/views/livewire/storefront/):
Create corresponding Blade views for each Livewire component. Use Tailwind CSS v4 with dark mode. Use Flux UI components where available (buttons, badges, inputs). Make them responsive.

### STOREFRONT ROUTES (update routes/web.php):
Add these storefront routes (with store.resolve middleware):
- GET / -> Storefront\Home
- GET /collections -> Storefront\Collections\Index
- GET /collections/{handle} -> Storefront\Collections\Show
- GET /products/{handle} -> Storefront\Products\Show
- GET /cart -> Storefront\Cart\Show
- GET /search -> Storefront\Search\Index
- GET /pages/{handle} -> Storefront\Pages\Show

## TESTS

### tests/Feature/Storefront/NavigationTest.php:
- builds navigation tree from menu items
- resolves URLs for page type items
- resolves URLs for collection type items
- resolves URLs for link type items

### tests/Feature/Storefront/PageTest.php:
- renders a published page
- returns 404 for draft page
- returns 404 for nonexistent page

After implementation:
1. Run: php artisan migrate:fresh
2. Run: php artisan test
3. Run: vendor/bin/pint --dirty
4. Fix any failing tests


Both Phase 2 and Phase 3 agents are running in parallel. Let me wait for them to complete.


Bash

sleep 300 && echo "Checking Phase 2 and 3 progress..."

Wait for Phase 2 and 3 agents


Checking Phase 2 and 3 progress...